From d80292572cacd8e1df76885562bb585246708c47 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Mon, 8 Jun 2026 10:32:00 -0700 Subject: [PATCH] Add EnableExperimentalMode support across all 6 SDKs 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> --- dotnet/src/Client.cs | 5 + dotnet/src/Types.cs | 10 ++ dotnet/test/Unit/CloneTests.cs | 35 ++++++ dotnet/test/Unit/GitHubTelemetryTests.cs | 45 ++++++++ dotnet/test/Unit/SerializationTests.cs | 34 ++++++ go/client.go | 2 + go/client_test.go | 57 ++++++++++ go/mode_empty.go | 8 ++ go/toolset_test.go | 36 ++++++ go/types.go | 10 ++ .../github/copilot/SessionRequestBuilder.java | 12 ++ .../copilot/rpc/CreateSessionRequest.java | 28 +++++ .../copilot/rpc/ResumeSessionConfig.java | 48 ++++++++ .../copilot/rpc/ResumeSessionRequest.java | 28 +++++ .../com/github/copilot/rpc/SessionConfig.java | 48 ++++++++ .../copilot/SessionRequestBuilderTest.java | 41 +++++++ nodejs/src/client.ts | 7 ++ nodejs/src/types.ts | 6 + nodejs/test/client.test.ts | 97 +++++++++++++++++ python/copilot/_mode.py | 8 ++ python/copilot/client.py | 15 +++ python/test_client.py | 103 ++++++++++++++++++ rust/src/mode.rs | 33 ++++++ rust/src/session.rs | 4 + rust/src/types.rs | 85 +++++++++++++++ rust/src/wire.rs | 4 + 26 files changed, 809 insertions(+) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 46ce7ba807..9b50d6942d 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -908,6 +908,7 @@ private void ApplyConfigDefaultsForMode(SessionConfigBase config) { if (_options.Mode == CopilotClientMode.Empty) { + config.EnableExperimentalMode ??= false; config.EnableSessionTelemetry ??= false; config.SkipEmbeddingRetrieval ??= true; config.EmbeddingCacheStorage ??= EmbeddingCacheStorageMode.InMemory; @@ -1146,6 +1147,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Provider, config.Capi, config.EnableSessionTelemetry, + config.EnableExperimentalMode, config.OnPermissionRequest != null ? true : null, config.OnUserInputRequest != null ? true : null, config.OnExitPlanModeRequest != null ? true : null, @@ -1360,6 +1362,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Provider, config.Capi, config.EnableSessionTelemetry, + config.EnableExperimentalMode, config.OnPermissionRequest != null ? true : null, config.OnUserInputRequest != null ? true : null, config.OnExitPlanModeRequest != null ? true : null, @@ -2715,6 +2718,7 @@ internal record CreateSessionRequest( ProviderConfig? Provider, CapiSessionOptions? Capi, bool? EnableSessionTelemetry, + bool? IsExperimentalMode, bool? RequestPermission, bool? RequestUserInput, bool? RequestExitPlanMode, @@ -2821,6 +2825,7 @@ internal record ResumeSessionRequest( ProviderConfig? Provider, CapiSessionOptions? Capi, bool? EnableSessionTelemetry, + bool? IsExperimentalMode, bool? RequestPermission, bool? RequestUserInput, bool? RequestExitPlanMode, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6b511117cd..e0b3d19145 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3068,6 +3068,7 @@ protected SessionConfigBase(SessionConfigBase? other) Providers = other.Providers is not null ? [.. other.Providers] : null; Models = other.Models is not null ? [.. other.Models] : null; EnableSessionTelemetry = other.EnableSessionTelemetry; + EnableExperimentalMode = other.EnableExperimentalMode; SkipCustomInstructions = other.SkipCustomInstructions; CustomAgentsLocalOnly = other.CustomAgentsLocalOnly; CoauthorEnabled = other.CoauthorEnabled; @@ -3270,6 +3271,15 @@ protected SessionConfigBase(SessionConfigBase? other) /// public bool? EnableSessionTelemetry { get; set; } + /// + /// Controls whether the session enables experimental features. + /// + /// + /// Defaults to in . + /// Otherwise, the runtime decides when left . + /// + public bool? EnableExperimentalMode { get; set; } + /// /// When , suppresses loading of custom instruction files /// (e.g. .github/copilot-instructions.md, AGENTS.md) from the working directory. diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index ec509ab169..e14003694d 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -78,6 +78,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Streaming = true, EnableCitations = true, EnableSessionTelemetry = false, + EnableExperimentalMode = true, EnableOnDemandInstructionDiscovery = true, IncludeSubAgentStreamingEvents = false, McpServers = new Dictionary { ["server1"] = new McpStdioServerConfig { Command = "echo" } }, @@ -122,6 +123,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.Streaming, clone.Streaming); Assert.Equal(original.EnableCitations, clone.EnableCitations); Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry); + Assert.Equal(original.EnableExperimentalMode, clone.EnableExperimentalMode); Assert.Equal(original.EnableOnDemandInstructionDiscovery, clone.EnableOnDemandInstructionDiscovery); Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents); Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); @@ -373,6 +375,19 @@ public void ResumeSessionConfig_Clone_CopiesEnableSessionTelemetry() Assert.False(clone.EnableSessionTelemetry); } + [Fact] + public void ResumeSessionConfig_Clone_CopiesEnableExperimentalMode() + { + var original = new ResumeSessionConfig + { + EnableExperimentalMode = true, + }; + + var clone = original.Clone(); + + Assert.True(clone.EnableExperimentalMode); + } + [Fact] public void ResumeSessionConfig_Clone_CopiesContinuePendingWork() { @@ -460,6 +475,26 @@ public void ResumeSessionConfig_Clone_PreservesEnableSessionTelemetryDefault() Assert.Null(clone.EnableSessionTelemetry); } + [Fact] + public void SessionConfig_Clone_PreservesEnableExperimentalModeDefault() + { + var original = new SessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableExperimentalMode); + } + + [Fact] + public void ResumeSessionConfig_Clone_PreservesEnableExperimentalModeDefault() + { + var original = new ResumeSessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableExperimentalMode); + } + [Fact] public void SessionConfig_Clone_CopiesEnableOnDemandInstructionDiscovery() { diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs index f82e0db6e0..24e633387e 100644 --- a/dotnet/test/Unit/GitHubTelemetryTests.cs +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -193,6 +193,50 @@ await server.SendGitHubTelemetryEventAsync(new Dictionary Assert.Equal(false, clientInfo.IsStaff); } + [Fact] + public async Task CreateSession_EmptyMode_Sends_IsExperimentalMode_False_By_Default() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + Mode = CopilotClientMode.Empty, + BaseDirectory = Path.GetTempPath(), + }); + await client.StartAsync(); + + await client.CreateSessionAsync(new SessionConfig + { + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated).ToList(), + }); + + var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured."); + Assert.True(createParams.TryGetProperty("isExperimentalMode", out var flag)); + Assert.False(flag.GetBoolean()); + } + + [Fact] + public async Task ResumeSession_EmptyMode_Sends_IsExperimentalMode_False_By_Default() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + Mode = CopilotClientMode.Empty, + BaseDirectory = Path.GetTempPath(), + }); + await client.StartAsync(); + + await client.ResumeSessionAsync("session-1", new ResumeSessionConfig + { + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated).ToList(), + }); + + var resumeParams = server.LastResumeParams ?? throw new InvalidOperationException("session.resume was not captured."); + Assert.True(resumeParams.TryGetProperty("isExperimentalMode", out var flag)); + Assert.False(flag.GetBoolean()); + } + private sealed class FakeTelemetryServer : IAsyncDisposable { private readonly TcpListener _listener; @@ -307,6 +351,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel "session.resume" => CaptureResume(request), "session.send" => new Dictionary { ["messageId"] = "message-1" }, "session.destroy" => new Dictionary(), + "session.options.update" => new Dictionary { ["success"] = true }, "runtime.shutdown" => new Dictionary(), _ => throw new InvalidOperationException($"Unexpected RPC method '{method}'."), }; diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 9108a81343..acad44f19c 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -706,6 +706,40 @@ public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptio Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); } + [Fact] + public void SessionRequests_CanSerializeEnableExperimentalMode_WithSdkOptions() + { + var options = GetSerializerOptions(); + + var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var createRequest = CreateInternalRequest( + createRequestType, + ("SessionId", "session-id"), + ("IsExperimentalMode", false)); + var createRoot = JsonDocument.Parse(JsonSerializer.Serialize(createRequest, createRequestType, options)).RootElement; + Assert.False(createRoot.GetProperty("isExperimentalMode").GetBoolean()); + + var createRequestOmitted = CreateInternalRequest( + createRequestType, + ("SessionId", "session-id")); + var createOmittedRoot = JsonDocument.Parse(JsonSerializer.Serialize(createRequestOmitted, createRequestType, options)).RootElement; + Assert.False(createOmittedRoot.TryGetProperty("isExperimentalMode", out _)); + + var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var resumeRequest = CreateInternalRequest( + resumeRequestType, + ("SessionId", "session-id"), + ("IsExperimentalMode", true)); + var resumeRoot = JsonDocument.Parse(JsonSerializer.Serialize(resumeRequest, resumeRequestType, options)).RootElement; + Assert.True(resumeRoot.GetProperty("isExperimentalMode").GetBoolean()); + + var resumeRequestOmitted = CreateInternalRequest( + resumeRequestType, + ("SessionId", "session-id")); + var resumeOmittedRoot = JsonDocument.Parse(JsonSerializer.Serialize(resumeRequestOmitted, resumeRequestType, options)).RootElement; + Assert.False(resumeOmittedRoot.TryGetProperty("isExperimentalMode", out _)); + } + [Fact] public void CreateSessionRequest_CanSerializeEnableOnDemandInstructionDiscovery_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index 292a5729e5..34f6d574a4 100644 --- a/go/client.go +++ b/go/client.go @@ -796,6 +796,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.EnableSessionTelemetry = config.EnableSessionTelemetry req.EnableCitations = config.EnableCitations req.SessionLimits = config.SessionLimits + req.IsExperimentalMode = config.EnableExperimentalMode req.SkipCustomInstructions = config.SkipCustomInstructions req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly req.CoauthorEnabled = config.CoauthorEnabled @@ -1122,6 +1123,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.Providers = config.Providers req.Models = config.Models req.EnableSessionTelemetry = config.EnableSessionTelemetry + req.IsExperimentalMode = config.EnableExperimentalMode req.SkipCustomInstructions = config.SkipCustomInstructions req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly req.CoauthorEnabled = config.CoauthorEnabled diff --git a/go/client_test.go b/go/client_test.go index 2301c990d3..2a6db6e861 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2333,6 +2333,63 @@ func TestCreateSessionRequest_RequestMCPApps(t *testing.T) { }) } +func TestSessionRequests_EnableExperimentalMode(t *testing.T) { + t.Run("create forwards enableExperimentalMode when explicitly false", func(t *testing.T) { + req := createSessionRequest{ + IsExperimentalMode: Bool(false), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["isExperimentalMode"] != false { + t.Errorf("Expected isExperimentalMode to be false, got %v", m["isExperimentalMode"]) + } + }) + + t.Run("create omits enableExperimentalMode when unset", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["isExperimentalMode"]; ok { + t.Error("Expected isExperimentalMode to be omitted when not set") + } + }) + + t.Run("resume forwards enableExperimentalMode when explicitly true", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + IsExperimentalMode: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["isExperimentalMode"] != true { + t.Errorf("Expected isExperimentalMode to be true, got %v", m["isExperimentalMode"]) + } + }) + + t.Run("resume omits enableExperimentalMode when unset", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["isExperimentalMode"]; ok { + t.Error("Expected isExperimentalMode to be omitted when not set") + } + }) +} + func TestResumeSessionRequest_RequestMCPApps(t *testing.T) { t.Run("sends requestMcpApps flag when EnableMCPApps is set", func(t *testing.T) { req := resumeSessionRequest{ diff --git a/go/mode_empty.go b/go/mode_empty.go index 46b8affcee..6057b2661f 100644 --- a/go/mode_empty.go +++ b/go/mode_empty.go @@ -122,6 +122,10 @@ func (c *Client) applyConfigDefaultsForMode(config *SessionConfig) { if c.options.Mode != ModeEmpty { return } + if config.EnableExperimentalMode == nil { + f := false + config.EnableExperimentalMode = &f + } if config.EnableSessionTelemetry == nil { f := false config.EnableSessionTelemetry = &f @@ -170,6 +174,10 @@ func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) { if c.options.Mode != ModeEmpty { return } + if config.EnableExperimentalMode == nil { + f := false + config.EnableExperimentalMode = &f + } if config.EnableSessionTelemetry == nil { f := false config.EnableSessionTelemetry = &f diff --git a/go/toolset_test.go b/go/toolset_test.go index f269ffdfe2..270d5b757f 100644 --- a/go/toolset_test.go +++ b/go/toolset_test.go @@ -229,6 +229,24 @@ func TestApplyConfigDefaultsForMode_emptyDefaultsTelemetryFalse(t *testing.T) { } } +func TestApplyConfigDefaultsForMode_emptyDefaultsExperimentalModeFalse(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableExperimentalMode == nil || *cfg.EnableExperimentalMode != false { + t.Errorf("expected experimental mode default false in empty mode, got %v", cfg.EnableExperimentalMode) + } +} + +func TestApplyConfigDefaultsForMode_copilotCliLeavesExperimentalModeNil(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableExperimentalMode != nil { + t.Errorf("non-empty mode must not default experimental mode") + } +} + func TestApplyConfigDefaultsForMode_emptyHonorsCallerTelemetry(t *testing.T) { c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) trueVal := true @@ -398,3 +416,21 @@ func TestApplyConfigDefaultsForMode_copilotCliLeavesMCPOAuthTokenStorageEmpty(t t.Errorf("non-empty mode must not default MCPOAuthTokenStorage, got %q", cfg.MCPOAuthTokenStorage) } } + +func TestApplyResumeDefaultsForMode_emptyDefaultsExperimentalModeFalse(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + cfg := &ResumeSessionConfig{} + c.applyResumeDefaultsForMode(cfg) + if cfg.EnableExperimentalMode == nil || *cfg.EnableExperimentalMode != false { + t.Errorf("expected experimental mode default false in empty mode, got %v", cfg.EnableExperimentalMode) + } +} + +func TestApplyResumeDefaultsForMode_copilotCliLeavesExperimentalModeNil(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + cfg := &ResumeSessionConfig{} + c.applyResumeDefaultsForMode(cfg) + if cfg.EnableExperimentalMode != nil { + t.Errorf("non-empty mode must not default experimental mode") + } +} diff --git a/go/types.go b/go/types.go index d625310b1b..9b3b53455f 100644 --- a/go/types.go +++ b/go/types.go @@ -1287,6 +1287,10 @@ type SessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig + // EnableExperimentalMode controls whether the session enables experimental + // features. When nil, it defaults to false in [ModeEmpty]; otherwise the + // runtime decides. + EnableExperimentalMode *bool // SkipCustomInstructions, when non-nil, controls whether the runtime loads // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. SkipCustomInstructions *bool @@ -1693,6 +1697,10 @@ type ResumeSessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig + // EnableExperimentalMode controls whether the session enables experimental + // features. When nil, it defaults to false in [ModeEmpty]; otherwise the + // runtime decides. + EnableExperimentalMode *bool // SkipCustomInstructions, when non-nil, controls whether the runtime loads // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. SkipCustomInstructions *bool @@ -2299,6 +2307,7 @@ type createSessionRequest struct { EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` EnableCitations *bool `json:"enableCitations,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` + IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` @@ -2390,6 +2399,7 @@ type resumeSessionRequest struct { EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` EnableCitations *bool `json:"enableCitations,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` + IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 1959a9ef83..593e881c1d 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Function; @@ -130,6 +131,8 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); config.getEnableCitations().ifPresent(request::setEnableCitations); request.setSessionLimits(config.getSessionLimits()); + experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) + .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); } @@ -260,6 +263,8 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); config.getEnableCitations().ifPresent(request::setEnableCitations); request.setSessionLimits(config.getSessionLimits()); + experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) + .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); } @@ -339,6 +344,13 @@ private static Boolean resolveCustomAgentsLocalOnly(Boolean customAgentsLocalOnl return mode == CopilotClientMode.EMPTY ? true : null; } + private static Optional experimentalModeForMode(CopilotClientMode mode, Boolean supplied) { + if (mode == CopilotClientMode.EMPTY) { + return Optional.of(supplied != null ? supplied : false); + } + return Optional.ofNullable(supplied); + } + /** * Configures a session with handlers from the given config. * diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 1755fddbfa..682d6fb7cf 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -199,6 +199,10 @@ public final class CreateSessionRequest { @JsonProperty("githubMcpToolConfig") private GitHubMcpToolConfig githubMcpToolConfig; + @JsonProperty("isExperimentalMode") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean isExperimentalMode; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -931,6 +935,30 @@ public void setGitHubMcpToolConfig(GitHubMcpToolConfig config) { this.githubMcpToolConfig = config; } + /** + * Gets the isExperimentalMode flag. + * + * @return the flag + */ + public Boolean getIsExperimentalMode() { + return isExperimentalMode; + } + + /** + * Sets the isExperimentalMode flag. + * + * @param isExperimentalMode + * the flag + */ + public void setIsExperimentalMode(boolean isExperimentalMode) { + this.isExperimentalMode = isExperimentalMode; + } + + /** Clears the isExperimentalMode setting, reverting to the default behavior. */ + public void clearIsExperimentalMode() { + this.isExperimentalMode = null; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 42419e5a36..373f5e8120 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -54,6 +54,7 @@ public class ResumeSessionConfig { private Boolean enableSessionTelemetry; private Boolean enableCitations; private SessionLimitsConfig sessionLimits; + private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; private Boolean coauthorEnabled; @@ -472,6 +473,52 @@ public ResumeSessionConfig setSessionLimits(SessionLimitsConfig sessionLimits) { return this; } + /** + * Clears the sessionLimits setting, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig clearSessionLimits() { + this.sessionLimits = null; + return this; + } + + /** + * Controls whether the session enables experimental features. + * + * @return {@code true} when experimental features are enabled, {@code false} + * when they are disabled, or empty to use the mode-specific default + */ + @JsonIgnore + public Optional getEnableExperimentalMode() { + return Optional.ofNullable(enableExperimentalMode); + } + + /** + * Controls whether the session enables experimental features. + * + * @param enableExperimentalMode + * {@code true} to enable experimental features; {@code false} to + * disable them + * @return this config for method chaining + */ + public ResumeSessionConfig setEnableExperimentalMode(boolean enableExperimentalMode) { + this.enableExperimentalMode = enableExperimentalMode; + return this; + } + + /** + * Clears the enableExperimentalMode setting. In {@link CopilotClientMode#EMPTY + * EMPTY} mode this defaults to {@code false}; otherwise the runtime decides. + * + * @return this instance for method chaining + */ + public ResumeSessionConfig clearEnableExperimentalMode() { + this.enableExperimentalMode = null; + return this; + } + /** * Gets whether custom instruction file loading is suppressed. * @@ -1846,6 +1893,7 @@ public ResumeSessionConfig clone() { copy.enableSessionTelemetry = this.enableSessionTelemetry; copy.enableCitations = this.enableCitations; copy.sessionLimits = this.sessionLimits; + copy.enableExperimentalMode = this.enableExperimentalMode; copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; copy.contextTier = this.contextTier; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 55bd08b915..c3559d7b2d 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -204,6 +204,10 @@ public final class ResumeSessionRequest { @JsonProperty("githubMcpToolConfig") private GitHubMcpToolConfig githubMcpToolConfig; + @JsonProperty("isExperimentalMode") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean isExperimentalMode; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -956,6 +960,30 @@ public void setGitHubMcpToolConfig(GitHubMcpToolConfig config) { this.githubMcpToolConfig = config; } + /** + * Gets the isExperimentalMode flag. + * + * @return the flag + */ + public Boolean getIsExperimentalMode() { + return isExperimentalMode; + } + + /** + * Sets the isExperimentalMode flag. + * + * @param isExperimentalMode + * the flag + */ + public void setIsExperimentalMode(boolean isExperimentalMode) { + this.isExperimentalMode = isExperimentalMode; + } + + /** Clears the isExperimentalMode setting, reverting to the default behavior. */ + public void clearIsExperimentalMode() { + this.isExperimentalMode = null; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index 7033ba5572..827a00df69 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -58,6 +58,7 @@ public class SessionConfig { private Boolean enableSessionTelemetry; private Boolean enableCitations; private SessionLimitsConfig sessionLimits; + private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; private Boolean coauthorEnabled; @@ -574,6 +575,52 @@ public SessionConfig setSessionLimits(SessionLimitsConfig sessionLimits) { return this; } + /** + * Clears the sessionLimits setting, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public SessionConfig clearSessionLimits() { + this.sessionLimits = null; + return this; + } + + /** + * Controls whether the session enables experimental features. + * + * @return {@code true} when experimental features are enabled, {@code false} + * when they are disabled, or empty to use the mode-specific default + */ + @JsonIgnore + public Optional getEnableExperimentalMode() { + return Optional.ofNullable(enableExperimentalMode); + } + + /** + * Controls whether the session enables experimental features. + * + * @param enableExperimentalMode + * {@code true} to enable experimental features; {@code false} to + * disable them + * @return this config instance for method chaining + */ + public SessionConfig setEnableExperimentalMode(boolean enableExperimentalMode) { + this.enableExperimentalMode = enableExperimentalMode; + return this; + } + + /** + * Clears the enableExperimentalMode setting. In {@link CopilotClientMode#EMPTY + * EMPTY} mode this defaults to {@code false}; otherwise the runtime decides. + * + * @return this instance for method chaining + */ + public SessionConfig clearEnableExperimentalMode() { + this.enableExperimentalMode = null; + return this; + } + /** * Gets whether custom instruction file loading is suppressed. * @@ -1981,6 +2028,7 @@ public SessionConfig clone() { copy.enableSessionTelemetry = this.enableSessionTelemetry; copy.enableCitations = this.enableCitations; copy.sessionLimits = this.sessionLimits; + copy.enableExperimentalMode = this.enableExperimentalMode; copy.skipCustomInstructions = this.skipCustomInstructions; copy.customAgentsLocalOnly = this.customAgentsLocalOnly; copy.coauthorEnabled = this.coauthorEnabled; diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 505f93a5a7..397ed607a0 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -134,6 +134,26 @@ void testBuildCreateRequestSetsReasoningSummary() { assertEquals("concise", request.getReasoningSummary()); } + @Test + void testBuildCreateRequestSetsEnableExperimentalMode() { + var config = new SessionConfig().setEnableExperimentalMode(false); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertFalse(request.getIsExperimentalMode()); + } + + @Test + void testBuildCreateRequestOmitsEnableExperimentalModeWhenNotSet() { + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(new SessionConfig()); + assertNull(request.getIsExperimentalMode()); + } + + @Test + void testBuildCreateRequestDefaultsEnableExperimentalModeFalseInEmptyMode() { + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "sid-empty", + CopilotClientMode.EMPTY); + assertFalse(request.getIsExperimentalMode()); + } + @Test void testBuildCreateRequestSetsContextTier() { var config = new SessionConfig().setContextTier("long_context"); @@ -240,6 +260,27 @@ void testBuildResumeRequestOmitsEnableSessionTelemetryWhenNotSet() { assertNull(request.getEnableSessionTelemetry()); } + @Test + void testBuildResumeRequestSetsEnableExperimentalMode() { + var config = new ResumeSessionConfig().setEnableExperimentalMode(true); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-1", config); + assertTrue(request.getIsExperimentalMode()); + } + + @Test + void testBuildResumeRequestOmitsEnableExperimentalModeWhenNotSet() { + var config = new ResumeSessionConfig(); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-1", config); + assertNull(request.getIsExperimentalMode()); + } + + @Test + void testBuildResumeRequestDefaultsEnableExperimentalModeFalseInEmptyMode() { + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-empty", new ResumeSessionConfig(), + CopilotClientMode.EMPTY); + assertFalse(request.getIsExperimentalMode()); + } + @Test void testBuildResumeRequestWithTools() { var tool = ToolDefinition.create("my_tool", "A tool", Map.of("type", "object"), diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 78290bf689..c0cb6ed17e 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1325,6 +1325,11 @@ export class CopilotClient { return {}; } + /** Mode-specific default for enableExperimentalMode. */ + private experimentalModeForMode(supplied: boolean | undefined): boolean | undefined { + return this.options.mode === "empty" ? (supplied ?? false) : supplied; + } + /** * Returns the systemMessage config to use, adjusted for the current mode. * In empty mode we ensure the environment_context section is removed @@ -1520,6 +1525,7 @@ export class CopilotClient { clientName: config.clientName, reasoningEffort: config.reasoningEffort, reasoningSummary: config.reasoningSummary, + isExperimentalMode: this.experimentalModeForMode(config.enableExperimentalMode), contextTier: config.contextTier, tools: config.tools?.map((tool) => ({ name: tool.name, @@ -1759,6 +1765,7 @@ export class CopilotClient { model: config.model, reasoningEffort: config.reasoningEffort, reasoningSummary: config.reasoningSummary, + isExperimentalMode: this.experimentalModeForMode(config.enableExperimentalMode), contextTier: config.contextTier, systemMessage: wireSystemMessage, availableTools: toolFilterOptions.availableTools, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index acba826673..b677808eca 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2074,6 +2074,12 @@ export interface SessionConfigBase { */ reasoningSummary?: ReasoningSummary; + /** + * Controls whether the session enables experimental features. + * Defaults to `false` in `"empty"` mode; otherwise the runtime decides when unset. + */ + enableExperimentalMode?: boolean; + /** * Context window tier for models that support it. Use "long_context" to pin * the session to the long-context tier; omit or use "default" otherwise. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 124261527a..78fbe0426c 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from "node:events"; import { PassThrough } from "stream"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, @@ -484,6 +487,100 @@ describe("CopilotClient", () => { expect(resumePayload.reasoningSummary).toBe("none"); }); + it("forwards enableExperimentalMode in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + enableExperimentalMode: false, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + enableExperimentalMode: true, + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.isExperimentalMode).toBe(false); + expect(resumePayload.isExperimentalMode).toBe(true); + }); + + it("defaults enableExperimentalMode by client mode", async () => { + const baseDirectory = mkdtempSync(join(tmpdir(), "copilot-sdk-node-empty-")); + const emptyClient = new CopilotClient({ mode: "empty", baseDirectory }); + await emptyClient.start(); + onTestFinished(() => emptyClient.forceStop()); + + const emptySpy = vi + .spyOn((emptyClient as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + if (method === "session.options.update") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + + const emptySession = await emptyClient.createSession({ + onPermissionRequest: approveAll, + availableTools: [], + }); + await emptyClient.resumeSession(emptySession.sessionId, { + onPermissionRequest: approveAll, + availableTools: [], + }); + + const emptyCreatePayload = emptySpy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const emptyResumePayload = emptySpy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(emptyCreatePayload.isExperimentalMode).toBe(false); + expect(emptyResumePayload.isExperimentalMode).toBe(false); + + const cliClient = new CopilotClient(); + await cliClient.start(); + onTestFinished(() => cliClient.forceStop()); + + const cliSpy = vi + .spyOn((cliClient as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const cliSession = await cliClient.createSession({ + onPermissionRequest: approveAll, + }); + await cliClient.resumeSession(cliSession.sessionId, { + onPermissionRequest: approveAll, + }); + + const cliCreatePayload = cliSpy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const cliResumePayload = cliSpy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(cliCreatePayload.isExperimentalMode).toBeUndefined(); + expect(cliResumePayload.isExperimentalMode).toBeUndefined(); + }); + it("forwards contextTier in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/_mode.py b/python/copilot/_mode.py index 77212891b4..1a9ed6e1f5 100644 --- a/python/copilot/_mode.py +++ b/python/copilot/_mode.py @@ -259,6 +259,14 @@ def _custom_agents_local_only_default( return _empty_mode_bool_default(mode, supplied, True) +def _enable_experimental_mode_default( + mode: CopilotClientMode | None, + supplied: bool | None, +) -> bool | None: + """Empty mode defaults experimental mode to False; caller value wins.""" + return _empty_mode_bool_default(mode, supplied, False) + + def _mcp_oauth_token_storage_default( mode: CopilotClientMode | None, supplied: Literal["persistent", "in-memory"] | None, diff --git a/python/copilot/client.py b/python/copilot/client.py index f7f0a4eb26..8f7c2daf0a 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -39,6 +39,7 @@ ToolSet, _custom_agents_local_only_default, _embedding_cache_storage_default, + _enable_experimental_mode_default, _enable_file_hooks_default, _enable_host_git_operations_default, _enable_on_demand_instruction_discovery_default, @@ -2019,6 +2020,7 @@ async def create_session( client_name: str | None = None, reasoning_effort: ReasoningEffort | None = None, reasoning_summary: ReasoningSummary | None = None, + enable_experimental_mode: bool | None = None, context_tier: ContextTier | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, @@ -2105,6 +2107,9 @@ async def create_session( reasoning_summary: Reasoning summary mode for supported models. Use ``"none"`` to suppress summary output regardless of whether reasoning is enabled. + enable_experimental_mode: Controls whether the session enables + experimental features. Defaults to ``False`` in ``"empty"`` + mode; otherwise the runtime decides when omitted. context_tier: Context window tier for models that support it. Use ``"long_context"`` to pin the session to the long-context tier. tools: Custom tools to register with the session. @@ -2297,6 +2302,7 @@ async def create_session( enable_session_store = _enable_session_store_default(mode, enable_session_store) enable_skills = _enable_skills_default(mode, enable_skills) custom_agents_local_only = _custom_agents_local_only_default(mode, custom_agents_local_only) + enable_experimental_mode = _enable_experimental_mode_default(mode, enable_experimental_mode) payload: dict[str, Any] = {} if model: @@ -2307,6 +2313,8 @@ async def create_session( payload["reasoningEffort"] = reasoning_effort if reasoning_summary: payload["reasoningSummary"] = reasoning_summary + if enable_experimental_mode is not None: + payload["isExperimentalMode"] = enable_experimental_mode if context_tier: payload["contextTier"] = context_tier if tool_defs: @@ -2708,6 +2716,7 @@ async def resume_session( client_name: str | None = None, reasoning_effort: ReasoningEffort | None = None, reasoning_summary: ReasoningSummary | None = None, + enable_experimental_mode: bool | None = None, context_tier: ContextTier | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, @@ -2795,6 +2804,9 @@ async def resume_session( reasoning_summary: Reasoning summary mode for supported models. Use ``"none"`` to suppress summary output regardless of whether reasoning is enabled. + enable_experimental_mode: Controls whether the session enables + experimental features. Defaults to ``False`` in ``"empty"`` + mode; otherwise the runtime decides when omitted. context_tier: Context window tier for models that support it. Use ``"long_context"`` to pin the session to the long-context tier. tools: Custom tools to register with the session. @@ -2987,6 +2999,7 @@ async def resume_session( enable_session_store = _enable_session_store_default(mode, enable_session_store) enable_skills = _enable_skills_default(mode, enable_skills) custom_agents_local_only = _custom_agents_local_only_default(mode, custom_agents_local_only) + enable_experimental_mode = _enable_experimental_mode_default(mode, enable_experimental_mode) payload: dict[str, Any] = {"sessionId": session_id} @@ -2998,6 +3011,8 @@ async def resume_session( payload["reasoningEffort"] = reasoning_effort if reasoning_summary: payload["reasoningSummary"] = reasoning_summary + if enable_experimental_mode is not None: + payload["isExperimentalMode"] = enable_experimental_mode if context_tier: payload["contextTier"] = context_tier if tool_defs: diff --git a/python/test_client.py b/python/test_client.py index ba353bde38..0f3ba8136e 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -7,6 +7,7 @@ import asyncio import inspect from datetime import UTC, datetime +from tempfile import TemporaryDirectory from unittest.mock import AsyncMock, Mock, patch import pytest @@ -577,6 +578,108 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_enable_experimental_mode(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + enable_experimental_mode=False, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + enable_experimental_mode=True, + ) + + assert captured["session.create"]["isExperimentalMode"] is False + assert captured["session.resume"]["isExperimentalMode"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_and_resume_session_default_enable_experimental_mode_by_mode(self): + with TemporaryDirectory() as base_directory: + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory=base_directory, + ) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + if method == "session.options.update": + return {"success": True} + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + ) + + assert captured["session.create"]["isExperimentalMode"] is False + assert captured["session.resume"]["isExperimentalMode"] is False + finally: + await client.force_stop() + + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + + assert "isExperimentalMode" not in captured["session.create"] + assert "isExperimentalMode" not in captured["session.resume"] + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_context_tier(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/mode.rs b/rust/src/mode.rs index a80420c19e..2b1ab897ce 100644 --- a/rust/src/mode.rs +++ b/rust/src/mode.rs @@ -291,6 +291,15 @@ pub(crate) fn memory_for_mode( } } +/// Returns the `enable_experimental_mode` value to send for the given mode. +pub(crate) fn experimental_mode_for_mode(mode: ClientMode, supplied: Option) -> Option { + if mode == ClientMode::Empty { + Some(supplied.unwrap_or(false)) + } else { + supplied + } +} + #[cfg(test)] mod tests { use super::*; @@ -534,4 +543,28 @@ mod tests { Some(MemoryConfiguration::enabled()) ); } + + #[test] + fn experimental_mode_defaults_false_in_empty_mode() { + assert_eq!( + experimental_mode_for_mode(ClientMode::Empty, None), + Some(false) + ); + assert_eq!( + experimental_mode_for_mode(ClientMode::Empty, Some(true)), + Some(true) + ); + assert_eq!( + experimental_mode_for_mode(ClientMode::Empty, Some(false)), + Some(false) + ); + } + + #[test] + fn experimental_mode_remains_runtime_controlled_in_copilot_cli_mode() { + assert_eq!( + experimental_mode_for_mode(ClientMode::CopilotCli, None), + None + ); + } } diff --git a/rust/src/session.rs b/rust/src/session.rs index 6919f28130..d505541a50 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -852,6 +852,8 @@ impl Client { config.system_message = crate::mode::system_message_for_mode(mode, config.system_message.take()); config.memory = crate::mode::memory_for_mode(mode, config.memory.take()); + config.enable_experimental_mode = + crate::mode::experimental_mode_for_mode(mode, config.enable_experimental_mode); if mode == crate::ClientMode::Empty { if config.enable_session_telemetry.is_none() { config.enable_session_telemetry = Some(false); @@ -1120,6 +1122,8 @@ impl Client { config.system_message = crate::mode::system_message_for_mode(mode, config.system_message.take()); config.memory = crate::mode::memory_for_mode(mode, config.memory.take()); + config.enable_experimental_mode = + crate::mode::experimental_mode_for_mode(mode, config.enable_experimental_mode); if mode == crate::ClientMode::Empty { if config.enable_session_telemetry.is_none() { config.enable_session_telemetry = Some(false); diff --git a/rust/src/types.rs b/rust/src/types.rs index b5c180e4ab..1c5e457a36 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2074,6 +2074,11 @@ pub struct SessionConfig { /// the initial create request and maintained via `session.options.update`. /// Defaults to `true` in [`crate::ClientMode::Empty`] when unset. pub custom_agents_local_only: Option, + /// Controls whether the session enables experimental features. + /// + /// Defaults to `false` in [`crate::ClientMode::Empty`] when unset; + /// in `copilot-cli` mode, leaving this unset lets the runtime decide. + pub enable_experimental_mode: Option, /// Whether to include the `Co-authored-by` trailer in commit messages. /// Applied via `session.options.update` after create/resume. Defaults to /// `false` in [`crate::ClientMode::Empty`] when unset. @@ -2166,6 +2171,7 @@ impl std::fmt::Debug for SessionConfig { .field("commands", &self.commands) .field("exp_assignments", &self.exp_assignments) .field("enable_managed_settings", &self.enable_managed_settings) + .field("enable_experimental_mode", &self.enable_experimental_mode) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -2287,6 +2293,7 @@ impl Default for SessionConfig { system_message_transform: None, skip_custom_instructions: None, custom_agents_local_only: None, + enable_experimental_mode: None, coauthor_enabled: None, manage_schedule_enabled: None, } @@ -2436,6 +2443,7 @@ impl SessionConfig { commands: wire_commands, exp_assignments: self.exp_assignments, enable_managed_settings: self.enable_managed_settings, + is_experimental_mode: self.enable_experimental_mode, }; let runtime = SessionConfigRuntime { @@ -2999,6 +3007,12 @@ impl SessionConfig { self } + /// Set [`enable_experimental_mode`](Self::enable_experimental_mode). + pub fn with_enable_experimental_mode(mut self, enable_experimental_mode: bool) -> Self { + self.enable_experimental_mode = Some(enable_experimental_mode); + self + } + /// Set [`Self::coauthor_enabled`]. pub fn with_coauthor_enabled(mut self, value: bool) -> Self { self.coauthor_enabled = Some(value); @@ -3262,6 +3276,11 @@ pub struct ResumeSessionConfig { pub skip_custom_instructions: Option, /// See [`SessionConfig::custom_agents_local_only`]. pub custom_agents_local_only: Option, + /// Controls whether the session enables experimental features. + /// + /// Defaults to `false` in [`crate::ClientMode::Empty`] when unset; + /// in `copilot-cli` mode, leaving this unset lets the runtime decide. + pub enable_experimental_mode: Option, /// See [`SessionConfig::coauthor_enabled`]. pub coauthor_enabled: Option, /// See [`SessionConfig::manage_schedule_enabled`]. @@ -3350,6 +3369,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("commands", &self.commands) .field("exp_assignments", &self.exp_assignments) .field("enable_managed_settings", &self.enable_managed_settings) + .field("enable_experimental_mode", &self.enable_experimental_mode) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -3504,6 +3524,7 @@ impl ResumeSessionConfig { commands: wire_commands, exp_assignments: self.exp_assignments, enable_managed_settings: self.enable_managed_settings, + is_experimental_mode: self.enable_experimental_mode, suppress_resume_event: self.suppress_resume_event, continue_pending_work: self.continue_pending_work, }; @@ -3609,6 +3630,7 @@ impl ResumeSessionConfig { system_message_transform: None, skip_custom_instructions: None, custom_agents_local_only: None, + enable_experimental_mode: None, coauthor_enabled: None, manage_schedule_enabled: None, } @@ -4145,6 +4167,12 @@ impl ResumeSessionConfig { self } + /// Set [`enable_experimental_mode`](Self::enable_experimental_mode). + pub fn with_enable_experimental_mode(mut self, enable_experimental_mode: bool) -> Self { + self.enable_experimental_mode = Some(enable_experimental_mode); + self + } + /// Set [`Self::coauthor_enabled`]. pub fn with_coauthor_enabled(mut self, value: bool) -> Self { self.coauthor_enabled = Some(value); @@ -7364,4 +7392,61 @@ mod permission_builder_tests { PermissionResult::Decision(PermissionDecision::ApproveOnce(_)) )); } + + #[test] + fn session_config_enable_experimental_mode_serializes_when_set() { + let cfg = SessionConfig::default().with_enable_experimental_mode(false); + assert_eq!(cfg.enable_experimental_mode, Some(false)); + + let (wire, _runtime) = cfg + .into_wire(Some(SessionId::from("experimental-mode"))) + .expect("enable_experimental_mode config has no duplicate handlers"); + assert_eq!(wire.is_experimental_mode, Some(false)); + + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["isExperimentalMode"], serde_json::Value::Bool(false)); + } + + #[test] + fn session_config_enable_experimental_mode_omitted_when_none() { + let cfg = SessionConfig::default(); + assert_eq!(cfg.enable_experimental_mode, None); + + let (wire, _runtime) = cfg + .into_wire(Some(SessionId::from("no-experimental-mode"))) + .expect("default config has no duplicate handlers"); + assert_eq!(wire.is_experimental_mode, None); + + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("isExperimentalMode").is_none()); + } + + #[test] + fn resume_session_config_enable_experimental_mode_serializes_when_set() { + let cfg = ResumeSessionConfig::new(SessionId::from("resume-experimental-mode")) + .with_enable_experimental_mode(false); + assert_eq!(cfg.enable_experimental_mode, Some(false)); + + let (wire, _runtime) = cfg + .into_wire() + .expect("resume enable_experimental_mode config has no duplicate handlers"); + assert_eq!(wire.is_experimental_mode, Some(false)); + + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["isExperimentalMode"], serde_json::Value::Bool(false)); + } + + #[test] + fn resume_session_config_enable_experimental_mode_omitted_when_none() { + let cfg = ResumeSessionConfig::new(SessionId::from("resume-no-experimental-mode")); + assert_eq!(cfg.enable_experimental_mode, None); + + let (wire, _runtime) = cfg + .into_wire() + .expect("default resume config has no duplicate handlers"); + assert_eq!(wire.is_experimental_mode, None); + + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("isExperimentalMode").is_none()); + } } diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 4dc7569093..1620e5ee1a 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -180,6 +180,8 @@ pub(crate) struct SessionCreateWire { pub exp_assignments: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enable_managed_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_experimental_mode: Option, } /// The exact JSON shape sent on the `session.resume` JSON-RPC request. @@ -323,4 +325,6 @@ pub(crate) struct SessionResumeWire { pub exp_assignments: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enable_managed_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_experimental_mode: Option, }