Skip to content

Commit eea0c98

Browse files
jmoseleyCopilot
andauthored
Add EnableExperimentalMode support across all 6 SDKs (#1600)
Adds per-session EnableExperimentalMode (enableExperimentalMode / enable_experimental_mode) to all SDK languages. The flag controls whether the session enables experimental features. Semantics are mode-aware and consistent across languages: - In "empty" mode the SDK sends false unless the caller explicitly sets true, so headless integrations are not silently opted into experimental behaviour. - In "copilot-cli" mode the field is omitted from the wire when nil/null/ None, letting the runtime decide (e.g. based on staff-user flags). Wire field is isExperimentalMode on both session.create and session.resume. Changes per language: - Rust: enable_experimental_mode on SessionConfig / ResumeSessionConfig, experimental_mode_for_mode helper in mode.rs, 6 new unit tests - .NET: EnableExperimentalMode on SessionOptions / SessionResumeOptions, mode-aware default in Client.CreateSessionAsync/ResumeSessionAsync - Node: enableExperimentalMode on SessionOptions / SessionResumeOptions - Python: enable_experimental_mode param on create_session/resume_session, _enable_experimental_mode_default helper in _mode.py - Go: EnableExperimentalMode on SessionConfig / ResumeSessionConfig, empty-mode default in mode_empty.go - Java: enableExperimentalMode on SessionConfig / ResumeSessionConfig, experimentalModeForMode helper in SessionRequestBuilder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 26b88ce commit eea0c98

26 files changed

Lines changed: 809 additions & 0 deletions

dotnet/src/Client.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,7 @@ private void ApplyConfigDefaultsForMode(SessionConfigBase config)
908908
{
909909
if (_options.Mode == CopilotClientMode.Empty)
910910
{
911+
config.EnableExperimentalMode ??= false;
911912
config.EnableSessionTelemetry ??= false;
912913
config.SkipEmbeddingRetrieval ??= true;
913914
config.EmbeddingCacheStorage ??= EmbeddingCacheStorageMode.InMemory;
@@ -1146,6 +1147,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
11461147
config.Provider,
11471148
config.Capi,
11481149
config.EnableSessionTelemetry,
1150+
config.EnableExperimentalMode,
11491151
config.OnPermissionRequest != null ? true : null,
11501152
config.OnUserInputRequest != null ? true : null,
11511153
config.OnExitPlanModeRequest != null ? true : null,
@@ -1360,6 +1362,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
13601362
config.Provider,
13611363
config.Capi,
13621364
config.EnableSessionTelemetry,
1365+
config.EnableExperimentalMode,
13631366
config.OnPermissionRequest != null ? true : null,
13641367
config.OnUserInputRequest != null ? true : null,
13651368
config.OnExitPlanModeRequest != null ? true : null,
@@ -2715,6 +2718,7 @@ internal record CreateSessionRequest(
27152718
ProviderConfig? Provider,
27162719
CapiSessionOptions? Capi,
27172720
bool? EnableSessionTelemetry,
2721+
bool? IsExperimentalMode,
27182722
bool? RequestPermission,
27192723
bool? RequestUserInput,
27202724
bool? RequestExitPlanMode,
@@ -2821,6 +2825,7 @@ internal record ResumeSessionRequest(
28212825
ProviderConfig? Provider,
28222826
CapiSessionOptions? Capi,
28232827
bool? EnableSessionTelemetry,
2828+
bool? IsExperimentalMode,
28242829
bool? RequestPermission,
28252830
bool? RequestUserInput,
28262831
bool? RequestExitPlanMode,

dotnet/src/Types.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3068,6 +3068,7 @@ protected SessionConfigBase(SessionConfigBase? other)
30683068
Providers = other.Providers is not null ? [.. other.Providers] : null;
30693069
Models = other.Models is not null ? [.. other.Models] : null;
30703070
EnableSessionTelemetry = other.EnableSessionTelemetry;
3071+
EnableExperimentalMode = other.EnableExperimentalMode;
30713072
SkipCustomInstructions = other.SkipCustomInstructions;
30723073
CustomAgentsLocalOnly = other.CustomAgentsLocalOnly;
30733074
CoauthorEnabled = other.CoauthorEnabled;
@@ -3270,6 +3271,15 @@ protected SessionConfigBase(SessionConfigBase? other)
32703271
/// </summary>
32713272
public bool? EnableSessionTelemetry { get; set; }
32723273

3274+
/// <summary>
3275+
/// Controls whether the session enables experimental features.
3276+
/// </summary>
3277+
/// <remarks>
3278+
/// Defaults to <see langword="false"/> in <see cref="CopilotClientMode.Empty"/>.
3279+
/// Otherwise, the runtime decides when left <see langword="null"/>.
3280+
/// </remarks>
3281+
public bool? EnableExperimentalMode { get; set; }
3282+
32733283
/// <summary>
32743284
/// When <see langword="true"/>, suppresses loading of custom instruction files
32753285
/// (e.g. <c>.github/copilot-instructions.md</c>, <c>AGENTS.md</c>) from the working directory.

dotnet/test/Unit/CloneTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
7878
Streaming = true,
7979
EnableCitations = true,
8080
EnableSessionTelemetry = false,
81+
EnableExperimentalMode = true,
8182
EnableOnDemandInstructionDiscovery = true,
8283
IncludeSubAgentStreamingEvents = false,
8384
McpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig { Command = "echo" } },
@@ -122,6 +123,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
122123
Assert.Equal(original.Streaming, clone.Streaming);
123124
Assert.Equal(original.EnableCitations, clone.EnableCitations);
124125
Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry);
126+
Assert.Equal(original.EnableExperimentalMode, clone.EnableExperimentalMode);
125127
Assert.Equal(original.EnableOnDemandInstructionDiscovery, clone.EnableOnDemandInstructionDiscovery);
126128
Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents);
127129
Assert.Equal(original.McpServers.Count, clone.McpServers!.Count);
@@ -373,6 +375,19 @@ public void ResumeSessionConfig_Clone_CopiesEnableSessionTelemetry()
373375
Assert.False(clone.EnableSessionTelemetry);
374376
}
375377

378+
[Fact]
379+
public void ResumeSessionConfig_Clone_CopiesEnableExperimentalMode()
380+
{
381+
var original = new ResumeSessionConfig
382+
{
383+
EnableExperimentalMode = true,
384+
};
385+
386+
var clone = original.Clone();
387+
388+
Assert.True(clone.EnableExperimentalMode);
389+
}
390+
376391
[Fact]
377392
public void ResumeSessionConfig_Clone_CopiesContinuePendingWork()
378393
{
@@ -460,6 +475,26 @@ public void ResumeSessionConfig_Clone_PreservesEnableSessionTelemetryDefault()
460475
Assert.Null(clone.EnableSessionTelemetry);
461476
}
462477

478+
[Fact]
479+
public void SessionConfig_Clone_PreservesEnableExperimentalModeDefault()
480+
{
481+
var original = new SessionConfig();
482+
483+
var clone = original.Clone();
484+
485+
Assert.Null(clone.EnableExperimentalMode);
486+
}
487+
488+
[Fact]
489+
public void ResumeSessionConfig_Clone_PreservesEnableExperimentalModeDefault()
490+
{
491+
var original = new ResumeSessionConfig();
492+
493+
var clone = original.Clone();
494+
495+
Assert.Null(clone.EnableExperimentalMode);
496+
}
497+
463498
[Fact]
464499
public void SessionConfig_Clone_CopiesEnableOnDemandInstructionDiscovery()
465500
{

dotnet/test/Unit/GitHubTelemetryTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,50 @@ await server.SendGitHubTelemetryEventAsync(new Dictionary<string, object?>
193193
Assert.Equal(false, clientInfo.IsStaff);
194194
}
195195

196+
[Fact]
197+
public async Task CreateSession_EmptyMode_Sends_IsExperimentalMode_False_By_Default()
198+
{
199+
await using var server = await FakeTelemetryServer.StartAsync();
200+
await using var client = new CopilotClient(new CopilotClientOptions
201+
{
202+
Connection = RuntimeConnection.ForUri(server.Url),
203+
Mode = CopilotClientMode.Empty,
204+
BaseDirectory = Path.GetTempPath(),
205+
});
206+
await client.StartAsync();
207+
208+
await client.CreateSessionAsync(new SessionConfig
209+
{
210+
AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated).ToList(),
211+
});
212+
213+
var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured.");
214+
Assert.True(createParams.TryGetProperty("isExperimentalMode", out var flag));
215+
Assert.False(flag.GetBoolean());
216+
}
217+
218+
[Fact]
219+
public async Task ResumeSession_EmptyMode_Sends_IsExperimentalMode_False_By_Default()
220+
{
221+
await using var server = await FakeTelemetryServer.StartAsync();
222+
await using var client = new CopilotClient(new CopilotClientOptions
223+
{
224+
Connection = RuntimeConnection.ForUri(server.Url),
225+
Mode = CopilotClientMode.Empty,
226+
BaseDirectory = Path.GetTempPath(),
227+
});
228+
await client.StartAsync();
229+
230+
await client.ResumeSessionAsync("session-1", new ResumeSessionConfig
231+
{
232+
AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated).ToList(),
233+
});
234+
235+
var resumeParams = server.LastResumeParams ?? throw new InvalidOperationException("session.resume was not captured.");
236+
Assert.True(resumeParams.TryGetProperty("isExperimentalMode", out var flag));
237+
Assert.False(flag.GetBoolean());
238+
}
239+
196240
private sealed class FakeTelemetryServer : IAsyncDisposable
197241
{
198242
private readonly TcpListener _listener;
@@ -307,6 +351,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
307351
"session.resume" => CaptureResume(request),
308352
"session.send" => new Dictionary<string, object?> { ["messageId"] = "message-1" },
309353
"session.destroy" => new Dictionary<string, object?>(),
354+
"session.options.update" => new Dictionary<string, object?> { ["success"] = true },
310355
"runtime.shutdown" => new Dictionary<string, object?>(),
311356
_ => throw new InvalidOperationException($"Unexpected RPC method '{method}'."),
312357
};

dotnet/test/Unit/SerializationTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,40 @@ public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptio
706706
Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean());
707707
}
708708

709+
[Fact]
710+
public void SessionRequests_CanSerializeEnableExperimentalMode_WithSdkOptions()
711+
{
712+
var options = GetSerializerOptions();
713+
714+
var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
715+
var createRequest = CreateInternalRequest(
716+
createRequestType,
717+
("SessionId", "session-id"),
718+
("IsExperimentalMode", false));
719+
var createRoot = JsonDocument.Parse(JsonSerializer.Serialize(createRequest, createRequestType, options)).RootElement;
720+
Assert.False(createRoot.GetProperty("isExperimentalMode").GetBoolean());
721+
722+
var createRequestOmitted = CreateInternalRequest(
723+
createRequestType,
724+
("SessionId", "session-id"));
725+
var createOmittedRoot = JsonDocument.Parse(JsonSerializer.Serialize(createRequestOmitted, createRequestType, options)).RootElement;
726+
Assert.False(createOmittedRoot.TryGetProperty("isExperimentalMode", out _));
727+
728+
var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
729+
var resumeRequest = CreateInternalRequest(
730+
resumeRequestType,
731+
("SessionId", "session-id"),
732+
("IsExperimentalMode", true));
733+
var resumeRoot = JsonDocument.Parse(JsonSerializer.Serialize(resumeRequest, resumeRequestType, options)).RootElement;
734+
Assert.True(resumeRoot.GetProperty("isExperimentalMode").GetBoolean());
735+
736+
var resumeRequestOmitted = CreateInternalRequest(
737+
resumeRequestType,
738+
("SessionId", "session-id"));
739+
var resumeOmittedRoot = JsonDocument.Parse(JsonSerializer.Serialize(resumeRequestOmitted, resumeRequestType, options)).RootElement;
740+
Assert.False(resumeOmittedRoot.TryGetProperty("isExperimentalMode", out _));
741+
}
742+
709743
[Fact]
710744
public void CreateSessionRequest_CanSerializeEnableOnDemandInstructionDiscovery_WithSdkOptions()
711745
{

go/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
796796
req.EnableSessionTelemetry = config.EnableSessionTelemetry
797797
req.EnableCitations = config.EnableCitations
798798
req.SessionLimits = config.SessionLimits
799+
req.IsExperimentalMode = config.EnableExperimentalMode
799800
req.SkipCustomInstructions = config.SkipCustomInstructions
800801
req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly
801802
req.CoauthorEnabled = config.CoauthorEnabled
@@ -1122,6 +1123,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
11221123
req.Providers = config.Providers
11231124
req.Models = config.Models
11241125
req.EnableSessionTelemetry = config.EnableSessionTelemetry
1126+
req.IsExperimentalMode = config.EnableExperimentalMode
11251127
req.SkipCustomInstructions = config.SkipCustomInstructions
11261128
req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly
11271129
req.CoauthorEnabled = config.CoauthorEnabled

go/client_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2333,6 +2333,63 @@ func TestCreateSessionRequest_RequestMCPApps(t *testing.T) {
23332333
})
23342334
}
23352335

2336+
func TestSessionRequests_EnableExperimentalMode(t *testing.T) {
2337+
t.Run("create forwards enableExperimentalMode when explicitly false", func(t *testing.T) {
2338+
req := createSessionRequest{
2339+
IsExperimentalMode: Bool(false),
2340+
}
2341+
data, err := json.Marshal(req)
2342+
if err != nil {
2343+
t.Fatalf("Failed to marshal: %v", err)
2344+
}
2345+
var m map[string]any
2346+
if err := json.Unmarshal(data, &m); err != nil {
2347+
t.Fatalf("Failed to unmarshal: %v", err)
2348+
}
2349+
if m["isExperimentalMode"] != false {
2350+
t.Errorf("Expected isExperimentalMode to be false, got %v", m["isExperimentalMode"])
2351+
}
2352+
})
2353+
2354+
t.Run("create omits enableExperimentalMode when unset", func(t *testing.T) {
2355+
req := createSessionRequest{}
2356+
data, _ := json.Marshal(req)
2357+
var m map[string]any
2358+
json.Unmarshal(data, &m)
2359+
if _, ok := m["isExperimentalMode"]; ok {
2360+
t.Error("Expected isExperimentalMode to be omitted when not set")
2361+
}
2362+
})
2363+
2364+
t.Run("resume forwards enableExperimentalMode when explicitly true", func(t *testing.T) {
2365+
req := resumeSessionRequest{
2366+
SessionID: "s1",
2367+
IsExperimentalMode: Bool(true),
2368+
}
2369+
data, err := json.Marshal(req)
2370+
if err != nil {
2371+
t.Fatalf("Failed to marshal: %v", err)
2372+
}
2373+
var m map[string]any
2374+
if err := json.Unmarshal(data, &m); err != nil {
2375+
t.Fatalf("Failed to unmarshal: %v", err)
2376+
}
2377+
if m["isExperimentalMode"] != true {
2378+
t.Errorf("Expected isExperimentalMode to be true, got %v", m["isExperimentalMode"])
2379+
}
2380+
})
2381+
2382+
t.Run("resume omits enableExperimentalMode when unset", func(t *testing.T) {
2383+
req := resumeSessionRequest{SessionID: "s1"}
2384+
data, _ := json.Marshal(req)
2385+
var m map[string]any
2386+
json.Unmarshal(data, &m)
2387+
if _, ok := m["isExperimentalMode"]; ok {
2388+
t.Error("Expected isExperimentalMode to be omitted when not set")
2389+
}
2390+
})
2391+
}
2392+
23362393
func TestResumeSessionRequest_RequestMCPApps(t *testing.T) {
23372394
t.Run("sends requestMcpApps flag when EnableMCPApps is set", func(t *testing.T) {
23382395
req := resumeSessionRequest{

go/mode_empty.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ func (c *Client) applyConfigDefaultsForMode(config *SessionConfig) {
122122
if c.options.Mode != ModeEmpty {
123123
return
124124
}
125+
if config.EnableExperimentalMode == nil {
126+
f := false
127+
config.EnableExperimentalMode = &f
128+
}
125129
if config.EnableSessionTelemetry == nil {
126130
f := false
127131
config.EnableSessionTelemetry = &f
@@ -170,6 +174,10 @@ func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) {
170174
if c.options.Mode != ModeEmpty {
171175
return
172176
}
177+
if config.EnableExperimentalMode == nil {
178+
f := false
179+
config.EnableExperimentalMode = &f
180+
}
173181
if config.EnableSessionTelemetry == nil {
174182
f := false
175183
config.EnableSessionTelemetry = &f

go/toolset_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,24 @@ func TestApplyConfigDefaultsForMode_emptyDefaultsTelemetryFalse(t *testing.T) {
229229
}
230230
}
231231

232+
func TestApplyConfigDefaultsForMode_emptyDefaultsExperimentalModeFalse(t *testing.T) {
233+
c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()})
234+
cfg := &SessionConfig{}
235+
c.applyConfigDefaultsForMode(cfg)
236+
if cfg.EnableExperimentalMode == nil || *cfg.EnableExperimentalMode != false {
237+
t.Errorf("expected experimental mode default false in empty mode, got %v", cfg.EnableExperimentalMode)
238+
}
239+
}
240+
241+
func TestApplyConfigDefaultsForMode_copilotCliLeavesExperimentalModeNil(t *testing.T) {
242+
c := NewClient(&ClientOptions{Mode: ModeCopilotCli})
243+
cfg := &SessionConfig{}
244+
c.applyConfigDefaultsForMode(cfg)
245+
if cfg.EnableExperimentalMode != nil {
246+
t.Errorf("non-empty mode must not default experimental mode")
247+
}
248+
}
249+
232250
func TestApplyConfigDefaultsForMode_emptyHonorsCallerTelemetry(t *testing.T) {
233251
c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()})
234252
trueVal := true
@@ -398,3 +416,21 @@ func TestApplyConfigDefaultsForMode_copilotCliLeavesMCPOAuthTokenStorageEmpty(t
398416
t.Errorf("non-empty mode must not default MCPOAuthTokenStorage, got %q", cfg.MCPOAuthTokenStorage)
399417
}
400418
}
419+
420+
func TestApplyResumeDefaultsForMode_emptyDefaultsExperimentalModeFalse(t *testing.T) {
421+
c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()})
422+
cfg := &ResumeSessionConfig{}
423+
c.applyResumeDefaultsForMode(cfg)
424+
if cfg.EnableExperimentalMode == nil || *cfg.EnableExperimentalMode != false {
425+
t.Errorf("expected experimental mode default false in empty mode, got %v", cfg.EnableExperimentalMode)
426+
}
427+
}
428+
429+
func TestApplyResumeDefaultsForMode_copilotCliLeavesExperimentalModeNil(t *testing.T) {
430+
c := NewClient(&ClientOptions{Mode: ModeCopilotCli})
431+
cfg := &ResumeSessionConfig{}
432+
c.applyResumeDefaultsForMode(cfg)
433+
if cfg.EnableExperimentalMode != nil {
434+
t.Errorf("non-empty mode must not default experimental mode")
435+
}
436+
}

0 commit comments

Comments
 (0)