Skip to content

Commit 7ad6dee

Browse files
syf2211roji
andauthored
forward CustomAgentsLocalOnly in session.create and session.resume (#1899)
* fix(dotnet): forward CustomAgentsLocalOnly in session.create and session.resume CustomAgentsLocalOnly was only sent via the post-create session.options.update call, which arrives after agent discovery has already completed. Mirror the Go SDK by including customAgentsLocalOnly in CreateSessionRequest and ResumeSessionRequest wire payloads. Fixes #1888 * fix: propagate custom agent locality across SDKs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6c5ceca0-342a-4fe7-8f7f-55637324e2b2 --------- Co-authored-by: syf2211 <syf2211@users.noreply.github.com> Co-authored-by: Shay Rojansky <roji@roji.org> Copilot-Session: 6c5ceca0-342a-4fe7-8f7f-55637324e2b2
1 parent 5fe2dd0 commit 7ad6dee

23 files changed

Lines changed: 597 additions & 22 deletions

dotnet/src/Client.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,7 @@ private void ApplyConfigDefaultsForMode(SessionConfigBase config)
916916
config.EnableSkills ??= false;
917917
config.Memory ??= new MemoryConfiguration { Enabled = false };
918918
config.McpOAuthTokenStorage ??= McpOAuthTokenStorageMode.InMemory;
919+
config.CustomAgentsLocalOnly ??= true;
919920
}
920921
}
921922

@@ -1159,6 +1160,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
11591160
config.Agent,
11601161
config.ConfigDirectory,
11611162
config.EnableConfigDiscovery,
1163+
config.CustomAgentsLocalOnly,
11621164
config.SkipEmbeddingRetrieval,
11631165
config.EmbeddingCacheStorage,
11641166
config.OrganizationCustomInstructions,
@@ -1363,6 +1365,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
13631365
config.WorkingDirectory,
13641366
config.ConfigDirectory,
13651367
config.EnableConfigDiscovery,
1368+
config.CustomAgentsLocalOnly,
13661369
config.SkipEmbeddingRetrieval,
13671370
config.EmbeddingCacheStorage,
13681371
config.OrganizationCustomInstructions,
@@ -2724,6 +2727,7 @@ internal record CreateSessionRequest(
27242727
string? Agent,
27252728
[property: JsonPropertyName("configDir")] string? ConfigDirectory,
27262729
bool? EnableConfigDiscovery,
2730+
[property: JsonPropertyName("customAgentsLocalOnly")] bool? CustomAgentsLocalOnly,
27272731
bool? SkipEmbeddingRetrieval,
27282732
EmbeddingCacheStorageMode? EmbeddingCacheStorage,
27292733
string? OrganizationCustomInstructions,
@@ -2820,6 +2824,7 @@ internal record ResumeSessionRequest(
28202824
string? WorkingDirectory,
28212825
[property: JsonPropertyName("configDir")] string? ConfigDirectory,
28222826
bool? EnableConfigDiscovery,
2827+
[property: JsonPropertyName("customAgentsLocalOnly")] bool? CustomAgentsLocalOnly,
28232828
bool? SkipEmbeddingRetrieval,
28242829
EmbeddingCacheStorageMode? EmbeddingCacheStorage,
28252830
string? OrganizationCustomInstructions,

dotnet/test/E2E/ClientOptionsE2ETests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,65 @@ public async Task Should_Omit_EnableSessionTelemetry_When_Not_Set()
179179
await session.DisposeAsync();
180180
}
181181

182+
[Fact]
183+
public async Task Should_Forward_CustomAgentsLocalOnly_In_Create_Wire_Request()
184+
{
185+
var (cliPath, capturePath) = await CreateFakeCliCaptureAsync();
186+
187+
await using var client = Ctx.CreateClient(options: new CopilotClientOptions
188+
{
189+
Connection = RuntimeConnection.ForStdio(path: cliPath, args: ["--capture-file", capturePath]),
190+
UseLoggedInUser = false,
191+
});
192+
193+
await client.StartAsync();
194+
195+
var session = await client.CreateSessionAsync(new SessionConfig
196+
{
197+
CustomAgentsLocalOnly = false,
198+
OnPermissionRequest = PermissionHandler.ApproveAll,
199+
});
200+
201+
using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath));
202+
var createRequest = GetCapturedRequestParams(capture.RootElement, "session.create");
203+
Assert.False(createRequest.GetProperty("customAgentsLocalOnly").GetBoolean());
204+
205+
await session.DisposeAsync();
206+
}
207+
208+
[Fact]
209+
public async Task Should_Forward_CustomAgentsLocalOnly_In_Resume_Wire_Request()
210+
{
211+
var (cliPath, capturePath) = await CreateFakeCliCaptureAsync();
212+
213+
await using var client = Ctx.CreateClient(options: new CopilotClientOptions
214+
{
215+
Connection = RuntimeConnection.ForStdio(path: cliPath, args: ["--capture-file", capturePath]),
216+
UseLoggedInUser = false,
217+
});
218+
219+
await client.StartAsync();
220+
221+
var createSession = await client.CreateSessionAsync(new SessionConfig
222+
{
223+
OnPermissionRequest = PermissionHandler.ApproveAll,
224+
});
225+
var sessionId = createSession.SessionId;
226+
await createSession.DisposeAsync();
227+
228+
var resumeSession = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
229+
{
230+
CustomAgentsLocalOnly = false,
231+
OnPermissionRequest = PermissionHandler.ApproveAll,
232+
});
233+
234+
using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath));
235+
var resumeRequest = GetCapturedRequestParams(capture.RootElement, "session.resume");
236+
Assert.False(resumeRequest.GetProperty("customAgentsLocalOnly").GetBoolean());
237+
238+
await resumeSession.DisposeAsync();
239+
}
240+
182241
[Fact]
183242
public async Task Should_Forward_Granular_Multitenancy_Fields_In_Create_Wire_Request()
184243
{
@@ -451,6 +510,7 @@ public async Task Should_Apply_Empty_Mode_Defaults_To_CreateSession_Wire_Request
451510
Assert.False(createRequest.GetProperty("enableHostGitOperations").GetBoolean());
452511
Assert.False(createRequest.GetProperty("enableSessionStore").GetBoolean());
453512
Assert.False(createRequest.GetProperty("enableSkills").GetBoolean());
513+
Assert.True(createRequest.GetProperty("customAgentsLocalOnly").GetBoolean());
454514
Assert.False(createRequest.TryGetProperty("organizationCustomInstructions", out _));
455515

456516
await session.DisposeAsync();
@@ -725,6 +785,7 @@ public async Task Should_Apply_Empty_Mode_Defaults_To_ResumeSession_Wire_Request
725785
Assert.False(resumeRequest.GetProperty("enableHostGitOperations").GetBoolean());
726786
Assert.False(resumeRequest.GetProperty("enableSessionStore").GetBoolean());
727787
Assert.False(resumeRequest.GetProperty("enableSkills").GetBoolean());
788+
Assert.True(resumeRequest.GetProperty("customAgentsLocalOnly").GetBoolean());
728789
Assert.False(resumeRequest.TryGetProperty("organizationCustomInstructions", out _));
729790

730791
await session.DisposeAsync();

dotnet/test/Unit/SerializationTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,58 @@ public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptio
593593
Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean());
594594
}
595595

596+
[Fact]
597+
public void CreateSessionRequest_CanSerializeCustomAgentsLocalOnly_WithSdkOptions()
598+
{
599+
var options = GetSerializerOptions();
600+
var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
601+
var request = CreateInternalRequest(
602+
requestType,
603+
("SessionId", "session-id"),
604+
("CustomAgentsLocalOnly", true));
605+
606+
var json = JsonSerializer.Serialize(request, requestType, options);
607+
using var document = JsonDocument.Parse(json);
608+
Assert.True(document.RootElement.GetProperty("customAgentsLocalOnly").GetBoolean());
609+
}
610+
611+
[Fact]
612+
public void ResumeSessionRequest_CanSerializeCustomAgentsLocalOnly_WithSdkOptions()
613+
{
614+
var options = GetSerializerOptions();
615+
var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
616+
var request = CreateInternalRequest(
617+
requestType,
618+
("SessionId", "session-id"),
619+
("CustomAgentsLocalOnly", true));
620+
621+
var json = JsonSerializer.Serialize(request, requestType, options);
622+
using var document = JsonDocument.Parse(json);
623+
Assert.True(document.RootElement.GetProperty("customAgentsLocalOnly").GetBoolean());
624+
}
625+
626+
[Fact]
627+
public void SessionRequests_OmitCustomAgentsLocalOnly_WhenUnset()
628+
{
629+
var options = GetSerializerOptions();
630+
631+
var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
632+
var createRequest = CreateInternalRequest(
633+
createRequestType,
634+
("SessionId", "session-id"));
635+
var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options);
636+
using var createDocument = JsonDocument.Parse(createJson);
637+
Assert.False(createDocument.RootElement.TryGetProperty("customAgentsLocalOnly", out _));
638+
639+
var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
640+
var resumeRequest = CreateInternalRequest(
641+
resumeRequestType,
642+
("SessionId", "session-id"));
643+
var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
644+
using var resumeDocument = JsonDocument.Parse(resumeJson);
645+
Assert.False(resumeDocument.RootElement.TryGetProperty("customAgentsLocalOnly", out _));
646+
}
647+
596648
[Fact]
597649
public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions()
598650
{

go/internal/e2e/client_options_e2e_test.go

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,12 @@ func TestClientOptionsE2E(t *testing.T) {
165165
EnableConfigDiscovery: copilot.Bool(true),
166166
EnableOnDemandInstructionDiscovery: copilot.Bool(true),
167167
IncludeSubAgentStreamingEvents: copilot.Bool(false),
168+
CustomAgentsLocalOnly: copilot.Bool(false),
168169
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
169170
})
170171
if err != nil {
171172
t.Fatalf("CreateSession failed: %v", err)
172173
}
173-
t.Cleanup(func() { session.Disconnect() })
174174

175175
updated := readCapture(t, capturePath)
176176
var createReq *capturedRequest
@@ -197,6 +197,107 @@ func TestClientOptionsE2E(t *testing.T) {
197197
if v, ok := params["includeSubAgentStreamingEvents"].(bool); !ok || v != false {
198198
t.Errorf("Expected session.create.params.includeSubAgentStreamingEvents=false, got %v", params["includeSubAgentStreamingEvents"])
199199
}
200+
if v, ok := params["customAgentsLocalOnly"].(bool); !ok || v != false {
201+
t.Errorf("Expected session.create.params.customAgentsLocalOnly=false, got %v", params["customAgentsLocalOnly"])
202+
}
203+
204+
sessionID := session.SessionID
205+
if err := session.Disconnect(); err != nil {
206+
t.Fatalf("Disconnect failed: %v", err)
207+
}
208+
resumed, err := client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{
209+
CustomAgentsLocalOnly: copilot.Bool(false),
210+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
211+
})
212+
if err != nil {
213+
t.Fatalf("ResumeSession failed: %v", err)
214+
}
215+
t.Cleanup(func() { _ = resumed.Disconnect() })
216+
217+
resumedCapture := readCapture(t, capturePath)
218+
for _, req := range resumedCapture.Requests {
219+
if req.Method != "session.resume" {
220+
continue
221+
}
222+
resumeParams, ok := req.Params.(map[string]any)
223+
if !ok {
224+
t.Fatalf("Expected session.resume params to be an object, got %T", req.Params)
225+
}
226+
if v, ok := resumeParams["customAgentsLocalOnly"].(bool); !ok || v != false {
227+
t.Errorf("Expected session.resume.params.customAgentsLocalOnly=false, got %v",
228+
resumeParams["customAgentsLocalOnly"])
229+
}
230+
return
231+
}
232+
t.Fatalf("session.resume request was not captured. Captured requests: %+v", resumedCapture.Requests)
233+
})
234+
235+
t.Run("should send empty-mode custom agent locality defaults in initial requests", func(t *testing.T) {
236+
ctx := testharness.NewTestContext(t)
237+
cliPath := filepath.Join(ctx.WorkDir, "fake-cli-empty-"+randomHex(t)+".js")
238+
capturePath := filepath.Join(ctx.WorkDir, "fake-cli-empty-capture-"+randomHex(t)+".json")
239+
if err := os.WriteFile(cliPath, []byte(fakeStdioCliScript), 0644); err != nil {
240+
t.Fatalf("Failed to write fake CLI script: %v", err)
241+
}
242+
243+
client := ctx.NewClient(func(opts *copilot.ClientOptions) {
244+
opts.Connection = copilot.StdioConnection{
245+
Path: cliPath,
246+
Args: []string{"--capture-file", capturePath},
247+
}
248+
opts.Mode = copilot.ModeEmpty
249+
opts.BaseDirectory = ctx.WorkDir
250+
opts.UseLoggedInUser = copilot.Bool(false)
251+
})
252+
t.Cleanup(func() { client.ForceStop() })
253+
254+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
255+
AvailableTools: []string{"builtin:ask_user"},
256+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
257+
})
258+
if err != nil {
259+
t.Fatalf("CreateSession failed: %v", err)
260+
}
261+
sessionID := session.SessionID
262+
if err := session.Disconnect(); err != nil {
263+
t.Fatalf("Disconnect failed: %v", err)
264+
}
265+
266+
resumed, err := client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{
267+
AvailableTools: []string{"builtin:ask_user"},
268+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
269+
})
270+
if err != nil {
271+
t.Fatalf("ResumeSession failed: %v", err)
272+
}
273+
t.Cleanup(func() { _ = resumed.Disconnect() })
274+
275+
capture := readCapture(t, capturePath)
276+
foundCreate := false
277+
foundResume := false
278+
for _, req := range capture.Requests {
279+
params, ok := req.Params.(map[string]any)
280+
if !ok {
281+
continue
282+
}
283+
switch req.Method {
284+
case "session.create":
285+
foundCreate = true
286+
if v, ok := params["customAgentsLocalOnly"].(bool); !ok || !v {
287+
t.Errorf("Expected session.create.params.customAgentsLocalOnly=true, got %v",
288+
params["customAgentsLocalOnly"])
289+
}
290+
case "session.resume":
291+
foundResume = true
292+
if v, ok := params["customAgentsLocalOnly"].(bool); !ok || !v {
293+
t.Errorf("Expected session.resume.params.customAgentsLocalOnly=true, got %v",
294+
params["customAgentsLocalOnly"])
295+
}
296+
}
297+
}
298+
if !foundCreate || !foundResume {
299+
t.Fatalf("Expected create and resume requests, got %+v", capture.Requests)
300+
}
200301
})
201302

202303
t.Run("should forward advanced session creation options to the CLI", func(t *testing.T) {
@@ -763,7 +864,7 @@ function handleMessage(message) {
763864
writeResponse(message.id, { message: "pong", protocolVersion: 3, timestamp: Date.now() });
764865
return;
765866
}
766-
if (message.method === "session.create") {
867+
if (message.method === "session.create" || message.method === "session.resume") {
767868
const sessionId = (message.params && message.params.sessionId) || "fake-session";
768869
writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null });
769870
return;

go/mode_empty.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ func (c *Client) applyConfigDefaultsForMode(config *SessionConfig) {
160160
if config.MCPOAuthTokenStorage == "" {
161161
config.MCPOAuthTokenStorage = "in-memory"
162162
}
163+
if config.CustomAgentsLocalOnly == nil {
164+
localOnly := true
165+
config.CustomAgentsLocalOnly = &localOnly
166+
}
163167
}
164168

165169
func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) {
@@ -204,6 +208,10 @@ func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) {
204208
if config.MCPOAuthTokenStorage == "" {
205209
config.MCPOAuthTokenStorage = "in-memory"
206210
}
211+
if config.CustomAgentsLocalOnly == nil {
212+
localOnly := true
213+
config.CustomAgentsLocalOnly = &localOnly
214+
}
207215
}
208216

209217
// updateSessionOptionsForMode applies the per-mode safe-defaults patch via

go/toolset_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ func TestApplyConfigDefaultsForMode_emptyDefaultsGranularFlags(t *testing.T) {
276276
if cfg.Memory == nil || cfg.Memory.Enabled != false {
277277
t.Errorf("expected Memory.Enabled=false in empty mode, got %v", cfg.Memory)
278278
}
279+
if cfg.CustomAgentsLocalOnly == nil || !*cfg.CustomAgentsLocalOnly {
280+
t.Errorf("expected CustomAgentsLocalOnly=true in empty mode, got %v", cfg.CustomAgentsLocalOnly)
281+
}
279282
}
280283

281284
func TestApplyConfigDefaultsForMode_emptyHonorsCallerGranularFlags(t *testing.T) {
@@ -291,6 +294,7 @@ func TestApplyConfigDefaultsForMode_emptyHonorsCallerGranularFlags(t *testing.T)
291294
EnableSessionStore: &trueVal,
292295
EnableSkills: &trueVal,
293296
Memory: &MemoryConfiguration{Enabled: true},
297+
CustomAgentsLocalOnly: &falseVal,
294298
}
295299
c.applyConfigDefaultsForMode(cfg)
296300
if *cfg.SkipEmbeddingRetrieval != false {
@@ -317,6 +321,9 @@ func TestApplyConfigDefaultsForMode_emptyHonorsCallerGranularFlags(t *testing.T)
317321
if cfg.Memory == nil || cfg.Memory.Enabled != true {
318322
t.Errorf("caller-supplied Memory must win")
319323
}
324+
if cfg.CustomAgentsLocalOnly == nil || *cfg.CustomAgentsLocalOnly {
325+
t.Errorf("caller-supplied CustomAgentsLocalOnly must win")
326+
}
320327
}
321328

322329
func TestApplyConfigDefaultsForMode_copilotCliLeavesGranularFlagsNil(t *testing.T) {
@@ -344,6 +351,25 @@ func TestApplyConfigDefaultsForMode_copilotCliLeavesGranularFlagsNil(t *testing.
344351
if cfg.Memory != nil {
345352
t.Errorf("non-empty mode must not default Memory")
346353
}
354+
if cfg.CustomAgentsLocalOnly != nil {
355+
t.Errorf("non-empty mode must not default CustomAgentsLocalOnly")
356+
}
357+
}
358+
359+
func TestApplyResumeDefaultsForMode_customAgentsLocalOnly(t *testing.T) {
360+
c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()})
361+
362+
cfg := &ResumeSessionConfig{}
363+
c.applyResumeDefaultsForMode(cfg)
364+
if cfg.CustomAgentsLocalOnly == nil || !*cfg.CustomAgentsLocalOnly {
365+
t.Errorf("expected CustomAgentsLocalOnly=true in empty mode, got %v", cfg.CustomAgentsLocalOnly)
366+
}
367+
368+
cfg = &ResumeSessionConfig{CustomAgentsLocalOnly: Bool(false)}
369+
c.applyResumeDefaultsForMode(cfg)
370+
if cfg.CustomAgentsLocalOnly == nil || *cfg.CustomAgentsLocalOnly {
371+
t.Errorf("caller-supplied CustomAgentsLocalOnly must win")
372+
}
347373
}
348374

349375
func TestApplyConfigDefaultsForMode_emptyDefaultsMCPOAuthTokenStorage(t *testing.T) {

0 commit comments

Comments
 (0)