Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
toolFilter.AvailableTools,
toolFilter.ExcludedTools,
config.Provider,
config.Capi,
config.EnableSessionTelemetry,
config.OnPermissionRequest != null ? true : null,
config.OnUserInputRequest != null ? true : null,
Expand Down Expand Up @@ -1159,6 +1160,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
toolFilter.AvailableTools,
toolFilter.ExcludedTools,
config.Provider,
config.Capi,
config.EnableSessionTelemetry,
config.OnPermissionRequest != null ? true : null,
config.OnUserInputRequest != null ? true : null,
Expand Down Expand Up @@ -2355,6 +2357,7 @@ internal record CreateSessionRequest(
IList<string>? AvailableTools,
IList<string>? ExcludedTools,
ProviderConfig? Provider,
CapiSessionOptions? Capi,
bool? EnableSessionTelemetry,
bool? RequestPermission,
bool? RequestUserInput,
Expand Down Expand Up @@ -2445,6 +2448,7 @@ internal record ResumeSessionRequest(
IList<string>? AvailableTools,
IList<string>? ExcludedTools,
ProviderConfig? Provider,
CapiSessionOptions? Capi,
bool? EnableSessionTelemetry,
bool? RequestPermission,
bool? RequestUserInput,
Expand Down Expand Up @@ -2569,6 +2573,7 @@ internal record HooksInvokeResponse(
[JsonSerializable(typeof(EmbeddingCacheStorageMode))]
[JsonSerializable(typeof(ModelCapabilitiesOverride))]
[JsonSerializable(typeof(ProviderConfig))]
[JsonSerializable(typeof(CapiSessionOptions))]
[JsonSerializable(typeof(ResumeSessionRequest))]
[JsonSerializable(typeof(ResumeSessionResponse))]
[JsonSerializable(typeof(SessionCapabilities))]
Expand Down
27 changes: 27 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2058,6 +2058,26 @@ public sealed class ProviderConfig
public int? MaxOutputTokens { get; set; }
}

/// <summary>
/// Provider-scoped options for the CAPI (Copilot API) provider.
/// </summary>
public sealed class CapiSessionOptions
{
/// <summary>
/// When <see langword="true"/>, opts out of the WebSocket transport for the CAPI Responses API
/// and uses the HTTP Responses transport instead.
/// </summary>
/// <remarks>
/// WebSocket transport is the default for CAPI Responses API requests when the model advertises
/// the <c>ws:/responses</c> endpoint. Set this option for users behind proxies where WebSockets
/// fail. This is equivalent to setting the <c>COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES</c>
/// environment variable. The option is scoped under the <c>capi</c> namespace because a single
/// session can host multiple providers, such as CAPI and BYOK, so transport choice is provider-level.
/// </remarks>
[JsonPropertyName("disableWebSocketResponses")]
public bool? DisableWebSocketResponses { get; set; }
}

/// <summary>
/// Azure OpenAI-specific provider options.
/// </summary>
Expand Down Expand Up @@ -2494,6 +2514,7 @@ protected SessionConfigBase(SessionConfigBase? other)
OnPermissionRequest = other.OnPermissionRequest;
OnUserInputRequest = other.OnUserInputRequest;
Provider = other.Provider;
Capi = other.Capi;
EnableSessionTelemetry = other.EnableSessionTelemetry;
SkipCustomInstructions = other.SkipCustomInstructions;
CustomAgentsLocalOnly = other.CustomAgentsLocalOnly;
Expand Down Expand Up @@ -2649,6 +2670,11 @@ protected SessionConfigBase(SessionConfigBase? other)
/// <summary>Custom model provider configuration for the session.</summary>
public ProviderConfig? Provider { get; set; }

/// <summary>
/// CAPI (Copilot API) provider-scoped configuration for the session.
/// </summary>
public CapiSessionOptions? Capi { get; set; }

/// <summary>
/// Enables or disables internal session telemetry for this session.
/// When <c>false</c>, disables session telemetry. When <c>null</c> (the default) or <c>true</c>,
Expand Down Expand Up @@ -3554,6 +3580,7 @@ public sealed class SystemMessageTransformRpcResponse
[JsonSerializable(typeof(PingRequest))]
[JsonSerializable(typeof(PingResponse))]
[JsonSerializable(typeof(ProviderConfig))]
[JsonSerializable(typeof(CapiSessionOptions))]
[JsonSerializable(typeof(SessionContext))]
[JsonSerializable(typeof(SessionLifecycleEvent))]
[JsonSerializable(typeof(SessionLifecycleEventMetadata))]
Expand Down
28 changes: 28 additions & 0 deletions dotnet/test/Unit/CloneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent,
CustomAgents = [new CustomAgentConfig { Name = "agent1", Model = "claude-haiku-4.5" }],
Agent = "agent1",
Capi = new CapiSessionOptions { DisableWebSocketResponses = true },
Cloud = new CloudSessionOptions
{
Repository = new CloudSessionRepository
Expand Down Expand Up @@ -123,6 +124,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count);
Assert.Equal(original.CustomAgents[0].Model, clone.CustomAgents[0].Model);
Assert.Equal(original.Agent, clone.Agent);
Assert.Same(original.Capi, clone.Capi);
Assert.Same(original.Cloud, clone.Cloud);
Assert.Equal(original.DefaultAgent!.ExcludedTools, clone.DefaultAgent!.ExcludedTools);
Assert.Equal(original.SkillDirectories, clone.SkillDirectories);
Expand Down Expand Up @@ -515,4 +517,30 @@ public void ResumeSessionConfig_Clone_CopiesMcpOAuthTokenStorage()

Assert.Equal(McpOAuthTokenStorageMode.Persistent, clone.McpOAuthTokenStorage);
}

[Fact]
public void SessionConfig_Clone_CopiesCapiOptions()
{
var original = new SessionConfig
{
Capi = new CapiSessionOptions { DisableWebSocketResponses = true },
};

var clone = original.Clone();

Assert.Same(original.Capi, clone.Capi);
}

[Fact]
public void ResumeSessionConfig_Clone_CopiesCapiOptions()
{
var original = new ResumeSessionConfig
{
Capi = new CapiSessionOptions { DisableWebSocketResponses = true },
};

var clone = original.Clone();

Assert.Same(original.Capi, clone.Capi);
}
}
70 changes: 70 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ public void ProviderConfig_CanSerializeHeaders_WithSdkOptions()
Assert.Equal(4096, deserialized.MaxOutputTokens);
}

[Fact]
public void CapiSessionOptions_CanSerializeDisableWebSocketResponses_WithSdkOptions()
{
var options = GetSerializerOptions();
var original = new CapiSessionOptions
{
DisableWebSocketResponses = true
};

var json = JsonSerializer.Serialize(original, options);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.True(root.GetProperty("disableWebSocketResponses").GetBoolean());

var deserialized = JsonSerializer.Deserialize<CapiSessionOptions>(json, options);
Assert.NotNull(deserialized);
Assert.True(deserialized.DisableWebSocketResponses);
}

[Fact]
public void ModelBilling_CanSerializeTokenPrices_WithSdkOptions()
{
Expand Down Expand Up @@ -221,6 +240,57 @@ public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptio
Assert.Equal("C:\\resume-instructions", root.GetProperty("instructionDirectories")[0].GetString());
}

[Fact]
public void SessionRequests_CanSerializeCapiOptions_WithSdkOptions()
{
var options = GetSerializerOptions();
var capi = new CapiSessionOptions { DisableWebSocketResponses = true };

var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
var createRequest = CreateInternalRequest(
createRequestType,
("SessionId", "session-id"),
("Capi", capi));

var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options);
using var createDocument = JsonDocument.Parse(createJson);
Assert.True(createDocument.RootElement.GetProperty("capi").GetProperty("disableWebSocketResponses").GetBoolean());

var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
var resumeRequest = CreateInternalRequest(
resumeRequestType,
("SessionId", "session-id"),
("Capi", capi));

var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
using var resumeDocument = JsonDocument.Parse(resumeJson);
Assert.True(resumeDocument.RootElement.GetProperty("capi").GetProperty("disableWebSocketResponses").GetBoolean());
}

[Fact]
public void SessionRequests_OmitCapiOptions_WhenUnset()
{
var options = GetSerializerOptions();

var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
var createRequest = CreateInternalRequest(
createRequestType,
("SessionId", "session-id"));

var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options);
using var createDocument = JsonDocument.Parse(createJson);
Assert.False(createDocument.RootElement.TryGetProperty("capi", out _));

var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
var resumeRequest = CreateInternalRequest(
resumeRequestType,
("SessionId", "session-id"));

var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
using var resumeDocument = JsonDocument.Parse(resumeJson);
Assert.False(resumeDocument.RootElement.TryGetProperty("capi", out _));
}

[Fact]
public void SessionRequests_CanSerializeReasoningSummary_WithSdkOptions()
{
Expand Down
2 changes: 2 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
req.ExcludedTools = excludedTools
req.ToolFilterPrecedence = precedence
req.Provider = config.Provider
req.Capi = config.Capi
req.EnableSessionTelemetry = config.EnableSessionTelemetry
req.SkipCustomInstructions = config.SkipCustomInstructions
req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly
Expand Down Expand Up @@ -976,6 +977,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
req.SystemMessage = wireSystemMessage
req.Tools = config.Tools
req.Provider = config.Provider
req.Capi = config.Capi
req.EnableSessionTelemetry = config.EnableSessionTelemetry
req.SkipCustomInstructions = config.SkipCustomInstructions
req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly
Expand Down
125 changes: 125 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,76 @@ func newRuntimeShutdownRpcPair(t *testing.T) (*jsonrpc2.Client, *jsonrpc2.Client
return rpcClient, server, shutdownCalled
}

func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) {
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
t.Cleanup(server.Stop)
client := &Client{
client: rpcClient,
RPC: rpc.NewServerRPC(rpcClient),
sessions: make(map[string]*Session),
}

createParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
createParams <- append(json.RawMessage(nil), params...)
sessionID := sessionIDFromParams(t, params)
return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil
})

_, err := client.CreateSession(t.Context(), &SessionConfig{
Capi: &CapiSessionOptions{DisableWebSocketResponses: Bool(true)},
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}
assertCapiDisableWebSocketResponses(t, <-createParams)

resumeParams := make(chan json.RawMessage, 1)
server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
resumeParams <- append(json.RawMessage(nil), params...)
return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil
})

_, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{
Capi: &CapiSessionOptions{DisableWebSocketResponses: Bool(true)},
})
if err != nil {
t.Fatalf("ResumeSessionWithOptions failed: %v", err)
}
assertCapiDisableWebSocketResponses(t, <-resumeParams)
}

func assertCapiDisableWebSocketResponses(t *testing.T, params json.RawMessage) {
t.Helper()

var decoded map[string]any
if err := json.Unmarshal(params, &decoded); err != nil {
t.Fatalf("failed to unmarshal request params: %v", err)
}
capi, ok := decoded["capi"].(map[string]any)
if !ok {
t.Fatalf("expected capi object in request params, got %T", decoded["capi"])
}
if capi["disableWebSocketResponses"] != true {
t.Fatalf("expected capi.disableWebSocketResponses=true, got %v", capi["disableWebSocketResponses"])
}
}

func sessionIDFromParams(t *testing.T, params json.RawMessage) string {
t.Helper()

var decoded struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(params, &decoded); err != nil {
t.Fatalf("failed to unmarshal request params: %v", err)
}
if decoded.SessionID == "" {
t.Fatal("expected generated sessionId in request params")
}
return decoded.SessionID
}

func assertRuntimeShutdownNotCalled(t *testing.T, shutdownCalled <-chan struct{}) {
t.Helper()
select {
Expand Down Expand Up @@ -1339,6 +1409,61 @@ func TestCreateSessionRequest_Cloud(t *testing.T) {
})
}

func TestSessionRequests_Capi(t *testing.T) {
t.Run("forwards capi options in session.create RPC", func(t *testing.T) {
req := createSessionRequest{
Capi: &CapiSessionOptions{DisableWebSocketResponses: 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)
}
capi, ok := m["capi"].(map[string]any)
if !ok {
t.Fatalf("Expected capi to be an object, got %T", m["capi"])
}
if capi["disableWebSocketResponses"] != true {
t.Errorf("Expected disableWebSocketResponses=true, got %v", capi["disableWebSocketResponses"])
}
})

t.Run("forwards capi options in session.resume RPC", func(t *testing.T) {
req := resumeSessionRequest{
SessionID: "s1",
Capi: &CapiSessionOptions{DisableWebSocketResponses: 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)
}
capi, ok := m["capi"].(map[string]any)
if !ok {
t.Fatalf("Expected capi to be an object, got %T", m["capi"])
}
if capi["disableWebSocketResponses"] != true {
t.Errorf("Expected disableWebSocketResponses=true, got %v", capi["disableWebSocketResponses"])
}
})

t.Run("omits capi from JSON when unset", func(t *testing.T) {
req := createSessionRequest{}
data, _ := json.Marshal(req)
var m map[string]any
json.Unmarshal(data, &m)
if _, ok := m["capi"]; ok {
t.Error("Expected capi to be omitted when unset")
}
})
}

func TestResumeSessionRequest_Commands(t *testing.T) {
t.Run("forwards commands in session.resume RPC", func(t *testing.T) {
req := resumeSessionRequest{
Expand Down
Loading
Loading