diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a67eb9681..bf51a2c9f 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -751,6 +751,7 @@ private void ApplyConfigDefaultsForMode(SessionConfigBase config) { if (_options.Mode == CopilotClientMode.Empty) { + config.EnableExperimentalMode ??= false; config.EnableSessionTelemetry ??= false; config.SkipEmbeddingRetrieval ??= true; config.EmbeddingCacheStorage ??= EmbeddingCacheStorageMode.InMemory; @@ -985,6 +986,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, @@ -1185,6 +1187,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, @@ -2427,6 +2430,7 @@ internal record CreateSessionRequest( ProviderConfig? Provider, CapiSessionOptions? Capi, bool? EnableSessionTelemetry, + bool? IsExperimentalMode, bool? RequestPermission, bool? RequestUserInput, bool? RequestExitPlanMode, @@ -2521,6 +2525,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 5ae965781..f1782ad88 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2726,6 +2726,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; @@ -2912,6 +2913,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 9df9a200c..294d198f6 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -76,6 +76,7 @@ public void SessionConfig_Clone_CopiesAllProperties() WorkingDirectory = "/workspace", Streaming = true, EnableSessionTelemetry = false, + EnableExperimentalMode = true, EnableOnDemandInstructionDiscovery = true, IncludeSubAgentStreamingEvents = false, McpServers = new Dictionary { ["server1"] = new McpStdioServerConfig { Command = "echo" } }, @@ -117,6 +118,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); Assert.Equal(original.Streaming, clone.Streaming); 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); @@ -359,6 +361,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() { @@ -446,6 +461,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/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 48ef2e553..0475f39f9 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -569,6 +569,69 @@ 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 ApplyConfigDefaultsForMode_EmptyDefaultsEnableExperimentalModeFalse() + { + var client = new CopilotClient(new CopilotClientOptions + { + Mode = CopilotClientMode.Empty, + BaseDirectory = System.IO.Path.GetTempPath(), + }); + var config = new SessionConfig(); + + InvokeApplyConfigDefaultsForMode(client, config); + + Assert.False(config.EnableExperimentalMode); + } + + [Fact] + public void ApplyConfigDefaultsForMode_CopilotCliLeavesEnableExperimentalModeNull() + { + var client = new CopilotClient(new CopilotClientOptions + { + Mode = CopilotClientMode.CopilotCli, + }); + var config = new ResumeSessionConfig(); + + InvokeApplyConfigDefaultsForMode(client, config); + + Assert.Null(config.EnableExperimentalMode); + } + [Fact] public void CreateSessionRequest_CanSerializeEnableOnDemandInstructionDiscovery_WithSdkOptions() { @@ -837,6 +900,15 @@ private static Type GetNestedType(Type containingType, string name) return type!; } + private static void InvokeApplyConfigDefaultsForMode(CopilotClient client, SessionConfigBase config) + { + var method = typeof(CopilotClient).GetMethod( + "ApplyConfigDefaultsForMode", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + method!.Invoke(client, [config]); + } + [Fact] public void HooksInvokeResponse_SerializesBoxedJsonElement_AsOutput() { diff --git a/go/client.go b/go/client.go index 970f04642..80530f91e 100644 --- a/go/client.go +++ b/go/client.go @@ -701,6 +701,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses 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 @@ -1003,6 +1004,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 d59c71c6f..e07055cd8 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1653,6 +1653,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 51fc34a4a..4687e44a9 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 @@ -166,6 +170,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 f8c38ef20..28c9f7864 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 @@ -372,3 +390,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 8a7df3c46..6caeda973 100644 --- a/go/types.go +++ b/go/types.go @@ -1017,6 +1017,10 @@ type SessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // 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 @@ -1376,6 +1380,10 @@ type ResumeSessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // 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 @@ -1964,6 +1972,7 @@ type createSessionRequest struct { Providers []NamedProviderConfig `json:"providers,omitempty"` Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` + IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` @@ -2047,6 +2056,7 @@ type resumeSessionRequest struct { Providers []NamedProviderConfig `json:"providers,omitempty"` Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,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/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 1a4994189..d73744848 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -573,7 +573,7 @@ public CompletableFuture createSession(SessionConfig config) { registeredIdHolder[0] = localSessionId; } - var request = SessionRequestBuilder.buildCreateRequest(config, localSessionId); + var request = SessionRequestBuilder.buildCreateRequest(config, localSessionId, options.getMode()); if (extracted.wireSystemMessage() != config.getSystemMessage()) { request.setSystemMessage(extracted.wireSystemMessage()); } @@ -611,6 +611,9 @@ public CompletableFuture createSession(SessionConfig config) { if (request.getEnableSkills() == null) { request.setEnableSkills(false); } + if (request.getIsExperimentalMode() == null) { + request.setIsExperimentalMode(false); + } if (request.getMemory() == null) { request.setMemory(new MemoryConfiguration().setEnabled(false)); } @@ -715,7 +718,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS session.registerTransformCallbacks(extracted.transformCallbacks()); } - var request = SessionRequestBuilder.buildResumeRequest(sessionId, config); + var request = SessionRequestBuilder.buildResumeRequest(sessionId, config, options.getMode()); if (extracted.wireSystemMessage() != config.getSystemMessage()) { request.setSystemMessage(extracted.wireSystemMessage()); } @@ -751,6 +754,9 @@ public CompletableFuture resumeSession(String sessionId, ResumeS if (request.getEnableSkills() == null) { request.setEnableSkills(false); } + if (request.getIsExperimentalMode() == null) { + request.setIsExperimentalMode(false); + } if (request.getMemory() == null) { request.setMemory(new MemoryConfiguration().setEnabled(false)); } diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 6000bdef8..e23238fd1 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; @@ -15,6 +16,7 @@ import com.github.copilot.rpc.NamedProviderConfig; import com.github.copilot.rpc.BearerTokenProvider; import com.github.copilot.rpc.CommandWireDefinition; +import com.github.copilot.rpc.CopilotClientMode; import com.github.copilot.rpc.ResumeSessionConfig; import com.github.copilot.rpc.ResumeSessionRequest; import com.github.copilot.rpc.SectionOverride; @@ -97,6 +99,10 @@ static ExtractedTransforms extractTransformCallbacks(SystemMessageConfig systemM * @return the built request object */ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sessionId) { + return buildCreateRequest(config, sessionId, CopilotClientMode.COPILOT_CLI); + } + + static CreateSessionRequest buildCreateRequest(SessionConfig config, String sessionId, CopilotClientMode mode) { var request = new CreateSessionRequest(); // Always request permission callbacks to enable deny-by-default behavior request.setRequestPermission(true); @@ -121,6 +127,8 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setProviders(config.getProviders()); request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); + experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) + .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); } @@ -212,6 +220,11 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) { * @return the built request object */ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionConfig config) { + return buildResumeRequest(sessionId, config, CopilotClientMode.COPILOT_CLI); + } + + static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionConfig config, + CopilotClientMode mode) { var request = new ResumeSessionRequest(); request.setSessionId(sessionId); // Always request permission callbacks to enable deny-by-default behavior @@ -237,6 +250,8 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setProviders(config.getProviders()); request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); + experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) + .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); } @@ -304,6 +319,13 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo return request; } + 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 8fc966c6f..1e58c76ff 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -178,6 +178,10 @@ public final class CreateSessionRequest { @JsonProperty("requestMcpApps") private Boolean requestMcpApps; + @JsonProperty("isExperimentalMode") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean isExperimentalMode; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -820,6 +824,30 @@ public void clearRequestMcpApps() { this.requestMcpApps = null; } + /** + * 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 e3e79eab0..7a1918ab3 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -51,6 +51,7 @@ public class ResumeSessionConfig { private List providers; private List models; private Boolean enableSessionTelemetry; + private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; private Boolean coauthorEnabled; @@ -382,6 +383,41 @@ public ResumeSessionConfig clearEnableSessionTelemetry() { 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. * @@ -1660,6 +1696,7 @@ public ResumeSessionConfig clone() { copy.providers = this.providers != null ? new ArrayList<>(this.providers) : null; copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; + 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 2b25875d7..f3e6face4 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -183,6 +183,10 @@ public final class ResumeSessionRequest { @JsonProperty("requestMcpApps") private Boolean requestMcpApps; + @JsonProperty("isExperimentalMode") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean isExperimentalMode; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -845,6 +849,30 @@ public void clearRequestMcpApps() { this.requestMcpApps = null; } + /** + * 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 38b357e7e..19ad4fe2e 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -55,6 +55,7 @@ public class SessionConfig { private List providers; private List models; private Boolean enableSessionTelemetry; + private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; private Boolean coauthorEnabled; @@ -484,6 +485,41 @@ public SessionConfig clearEnableSessionTelemetry() { 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. * @@ -1792,6 +1828,7 @@ public SessionConfig clone() { copy.providers = this.providers != null ? new ArrayList<>(this.providers) : null; copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; + 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 5849a6b88..90b742d01 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -16,6 +16,7 @@ import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; import com.github.copilot.rpc.CloudSessionRepository; +import com.github.copilot.rpc.CopilotClientMode; import com.github.copilot.rpc.CreateSessionRequest; import com.github.copilot.rpc.DefaultAgentConfig; import com.github.copilot.rpc.ElicitationHandler; @@ -100,6 +101,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"); @@ -193,6 +214,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 53686a6ca..743a9a5c5 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1194,6 +1194,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 @@ -1383,6 +1388,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, @@ -1578,6 +1584,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 e354bd821..3e3c25eeb 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1680,6 +1680,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 96d7da30c..091cc2ab2 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from "node:events"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { PassThrough } from "stream"; import { @@ -156,6 +159,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 d8baf2663..228c45253 100644 --- a/python/copilot/_mode.py +++ b/python/copilot/_mode.py @@ -251,6 +251,14 @@ def _enable_skills_default( return _empty_mode_bool_default(mode, supplied, False) +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 c7d11d12b..fdf572e2f 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -37,6 +37,7 @@ CopilotClientMode, ToolSet, _embedding_cache_storage_default, + _enable_experimental_mode_default, _enable_file_hooks_default, _enable_host_git_operations_default, _enable_on_demand_instruction_discovery_default, @@ -1652,6 +1653,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, @@ -1730,6 +1732,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. @@ -1898,6 +1903,7 @@ async def create_session( ) enable_session_store = _enable_session_store_default(mode, enable_session_store) enable_skills = _enable_skills_default(mode, enable_skills) + enable_experimental_mode = _enable_experimental_mode_default(mode, enable_experimental_mode) payload: dict[str, Any] = {} if model: @@ -1908,6 +1914,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: @@ -2274,6 +2282,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, @@ -2353,6 +2362,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. @@ -2521,6 +2533,7 @@ async def resume_session( ) enable_session_store = _enable_session_store_default(mode, enable_session_store) enable_skills = _enable_skills_default(mode, enable_skills) + enable_experimental_mode = _enable_experimental_mode_default(mode, enable_experimental_mode) payload: dict[str, Any] = {"sessionId": session_id} @@ -2532,6 +2545,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 f3f46c4d8..81b877946 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -5,6 +5,7 @@ """ from datetime import UTC, datetime +from tempfile import TemporaryDirectory from unittest.mock import AsyncMock, Mock, patch import pytest @@ -213,6 +214,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 580a57bbd..d7dd7ff97 100644 --- a/rust/src/mode.rs +++ b/rust/src/mode.rs @@ -266,6 +266,18 @@ pub(crate) fn system_message_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 + } +} + /// Returns the memory configuration to use, adjusted for the current mode. /// /// In [`ClientMode::Empty`] the memory feature defaults to disabled so an app @@ -482,6 +494,27 @@ mod tests { assert_eq!(env.action.as_deref(), Some("remove")); } + #[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 + ); + } + #[test] fn memory_copilot_cli_leaves_unset_when_not_supplied() { assert_eq!(memory_for_mode(ClientMode::CopilotCli, None), None); diff --git a/rust/src/session.rs b/rust/src/session.rs index 18b91b437..b3bd3fe24 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -838,6 +838,8 @@ impl Client { crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?; config.system_message = crate::mode::system_message_for_mode(mode, config.system_message.take()); + config.enable_experimental_mode = + crate::mode::experimental_mode_for_mode(mode, config.enable_experimental_mode); config.memory = crate::mode::memory_for_mode(mode, config.memory.take()); if mode == crate::ClientMode::Empty { if config.enable_session_telemetry.is_none() { @@ -1096,6 +1098,8 @@ impl Client { crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?; config.system_message = crate::mode::system_message_for_mode(mode, config.system_message.take()); + config.enable_experimental_mode = + crate::mode::experimental_mode_for_mode(mode, config.enable_experimental_mode); config.memory = crate::mode::memory_for_mode(mode, config.memory.take()); if mode == crate::ClientMode::Empty { if config.enable_session_telemetry.is_none() { diff --git a/rust/src/types.rs b/rust/src/types.rs index 75408db02..6306e4539 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1659,6 +1659,11 @@ pub struct SessionConfig { /// /// Defaults to `None` (treated as `false`). pub enable_mcp_apps: Option, + /// Controls whether the session enables experimental features. + /// + /// Defaults to `false` in [`crate::ClientMode::Empty`]. Otherwise, the + /// runtime decides when this is `None`. + pub enable_experimental_mode: Option, /// Skill directory paths passed through to the GitHub Copilot CLI. pub skill_directories: Option>, /// Additional directories to search for custom instruction files. @@ -1860,6 +1865,7 @@ impl std::fmt::Debug for SessionConfig { .field("enable_session_store", &self.enable_session_store) .field("enable_skills", &self.enable_skills) .field("enable_mcp_apps", &self.enable_mcp_apps) + .field("enable_experimental_mode", &self.enable_experimental_mode) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("plugin_directories", &self.plugin_directories) @@ -1962,6 +1968,7 @@ impl Default for SessionConfig { enable_skills: None, embedding_cache_storage: None, enable_mcp_apps: None, + enable_experimental_mode: None, skill_directories: None, instruction_directories: None, plugin_directories: None, @@ -2112,6 +2119,7 @@ impl SessionConfig { request_auto_mode_switch, request_elicitation, request_mcp_apps: self.enable_mcp_apps.unwrap_or(false), + is_experimental_mode: self.enable_experimental_mode, hooks: hooks_flag, skill_directories: self.skill_directories, instruction_directories: self.instruction_directories, @@ -2462,6 +2470,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 skill directory paths passed through to the CLI. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -2754,6 +2768,11 @@ pub struct ResumeSessionConfig { /// Enable MCP Apps (SEP-1865) UI passthrough on resume. See /// [`SessionConfig::enable_mcp_apps`]. Defaults to `None` (treated as `false`). pub enable_mcp_apps: Option, + /// Controls whether the session enables experimental features. + /// + /// Defaults to `false` in [`crate::ClientMode::Empty`]. Otherwise, the + /// runtime decides when this is `None`. + pub enable_experimental_mode: Option, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. pub skill_directories: Option>, /// Additional directories to search for custom instruction files on @@ -2923,6 +2942,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("enable_session_store", &self.enable_session_store) .field("enable_skills", &self.enable_skills) .field("enable_mcp_apps", &self.enable_mcp_apps) + .field("enable_experimental_mode", &self.enable_experimental_mode) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("plugin_directories", &self.plugin_directories) @@ -3071,6 +3091,7 @@ impl ResumeSessionConfig { request_auto_mode_switch, request_elicitation, request_mcp_apps: self.enable_mcp_apps.unwrap_or(false), + is_experimental_mode: self.enable_experimental_mode, hooks: hooks_flag, skill_directories: self.skill_directories, instruction_directories: self.instruction_directories, @@ -3153,6 +3174,7 @@ impl ResumeSessionConfig { enable_skills: None, embedding_cache_storage: None, enable_mcp_apps: None, + enable_experimental_mode: None, skill_directories: None, instruction_directories: None, plugin_directories: None, @@ -3477,6 +3499,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 skill directory paths passed through to the CLI on resume. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -5181,6 +5209,63 @@ mod tests { assert_eq!(json["expAssignments"], assignments); } + #[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()); + } + #[test] #[allow(clippy::field_reassign_with_default)] fn session_config_into_wire_serializes_bucket_b_fields() { diff --git a/rust/src/wire.rs b/rust/src/wire.rs index e6dad66d5..71fa72df9 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -157,6 +157,8 @@ pub(crate) struct SessionCreateWire { pub commands: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub exp_assignments: 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. @@ -277,4 +279,6 @@ pub(crate) struct SessionResumeWire { pub continue_pending_work: Option, #[serde(skip_serializing_if = "Option::is_none")] pub exp_assignments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_experimental_mode: Option, }