diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5c92adfae..372df4d4b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -602,6 +602,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.AvailableTools, config.ExcludedTools, config.Provider, + config.EnableSessionTelemetry, (bool?)true, config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, @@ -753,6 +754,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.AvailableTools, config.ExcludedTools, config.Provider, + config.EnableSessionTelemetry, (bool?)true, config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, @@ -1911,6 +1913,7 @@ internal record CreateSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, bool? Hooks, @@ -1967,6 +1970,7 @@ internal record ResumeSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, bool? Hooks, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index d536f57fc..adf629eef 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1859,6 +1859,7 @@ protected SessionConfig(SessionConfig? other) OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; + EnableSessionTelemetry = other.EnableSessionTelemetry; ReasoningEffort = other.ReasoningEffort; CreateSessionFsHandler = other.CreateSessionFsHandler; GitHubToken = other.GitHubToken; @@ -1940,6 +1941,17 @@ protected SessionConfig(SessionConfig? other) /// public ProviderConfig? Provider { get; set; } + /// + /// Enables or disables internal session telemetry for this session. + /// When false, disables session telemetry. When null (the default) or true, + /// telemetry is enabled for GitHub-authenticated sessions. + /// When a custom (BYOK) is configured, session telemetry is + /// always disabled regardless of this setting. + /// This is independent of , which configures + /// OpenTelemetry export for observability. + /// + public bool? EnableSessionTelemetry { get; set; } + /// /// Handler for permission requests from the server. /// When provided, the server will call this handler to request permission for operations. @@ -2124,6 +2136,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; + EnableSessionTelemetry = other.EnableSessionTelemetry; ReasoningEffort = other.ReasoningEffort; CreateSessionFsHandler = other.CreateSessionFsHandler; GitHubToken = other.GitHubToken; @@ -2174,6 +2187,17 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public ProviderConfig? Provider { get; set; } + /// + /// Enables or disables internal session telemetry for this session. + /// When false, disables session telemetry. When null (the default) or true, + /// telemetry is enabled for GitHub-authenticated sessions. + /// When a custom (BYOK) is configured, session telemetry is + /// always disabled regardless of this setting. + /// This is independent of , which configures + /// OpenTelemetry export for observability. + /// + public bool? EnableSessionTelemetry { get; set; } + /// /// Reasoning effort level for models that support it. /// Valid values: "low", "medium", "high", "xhigh". diff --git a/dotnet/test/E2E/ClientOptionsE2ETests.cs b/dotnet/test/E2E/ClientOptionsE2ETests.cs index 31627f5a3..14263de79 100644 --- a/dotnet/test/E2E/ClientOptionsE2ETests.cs +++ b/dotnet/test/E2E/ClientOptionsE2ETests.cs @@ -159,6 +159,63 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli() await session.DisposeAsync(); } + [Fact] + public async Task Should_Forward_EnableSessionTelemetry_In_Wire_Request() + { + var (cliPath, capturePath) = await CreateFakeCliCaptureAsync(); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + AutoStart = false, + CliPath = cliPath, + CliArgs = ["--capture-file", capturePath], + UseLoggedInUser = false, + }); + + await client.StartAsync(); + + // When explicitly set to false, it should appear in the wire request + var session = await client.CreateSessionAsync(new SessionConfig + { + EnableSessionTelemetry = false, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath)); + var createRequest = GetCapturedRequestParams(capture.RootElement, "session.create"); + Assert.False(createRequest.GetProperty("enableSessionTelemetry").GetBoolean()); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Omit_EnableSessionTelemetry_When_Not_Set() + { + var (cliPath, capturePath) = await CreateFakeCliCaptureAsync(); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + AutoStart = false, + CliPath = cliPath, + CliArgs = ["--capture-file", capturePath], + UseLoggedInUser = false, + }); + + await client.StartAsync(); + + // When omitted (null/default), the field should not be present in the wire request + var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath)); + var createRequest = GetCapturedRequestParams(capture.RootElement, "session.create"); + Assert.False(createRequest.TryGetProperty("enableSessionTelemetry", out _)); + + await session.DisposeAsync(); + } + [Fact] public async Task Should_Propagate_Activity_TraceContext_To_Session_Create_And_Send() { diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index d0b0d5162..ed2070b50 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -92,6 +92,7 @@ public void SessionConfig_Clone_CopiesAllProperties() ExcludedTools = ["tool3"], WorkingDirectory = "/workspace", Streaming = true, + EnableSessionTelemetry = false, IncludeSubAgentStreamingEvents = false, McpServers = new Dictionary { ["server1"] = new McpStdioServerConfig { Command = "echo" } }, CustomAgents = [new CustomAgentConfig { Name = "agent1" }], @@ -113,6 +114,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.ExcludedTools, clone.ExcludedTools); Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); Assert.Equal(original.Streaming, clone.Streaming); + Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry); Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents); Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count); @@ -317,6 +319,19 @@ public void ResumeSessionConfig_Clone_PreservesIncludeSubAgentStreamingEventsDef Assert.True(clone.IncludeSubAgentStreamingEvents); } + [Fact] + public void ResumeSessionConfig_Clone_CopiesEnableSessionTelemetry() + { + var original = new ResumeSessionConfig + { + EnableSessionTelemetry = false, + }; + + var clone = original.Clone(); + + Assert.False(clone.EnableSessionTelemetry); + } + [Fact] public void ResumeSessionConfig_Clone_CopiesContinuePendingWork() { @@ -339,4 +354,24 @@ public void ResumeSessionConfig_Clone_PreservesContinuePendingWorkDefault() Assert.Null(clone.ContinuePendingWork); } + + [Fact] + public void SessionConfig_Clone_PreservesEnableSessionTelemetryDefault() + { + var original = new SessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableSessionTelemetry); + } + + [Fact] + public void ResumeSessionConfig_Clone_PreservesEnableSessionTelemetryDefault() + { + var original = new ResumeSessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableSessionTelemetry); + } } diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index e58b256f4..713c46abd 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -126,6 +126,38 @@ public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptio Assert.Equal("C:\\resume-instructions", root.GetProperty("instructionDirectories")[0].GetString()); } + [Fact] + public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableSessionTelemetry", false)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); + } + + [Fact] + public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableSessionTelemetry", false)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); + } + [Fact] public void McpHttpServerConfig_CanSerializeOauthOptions_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index 5e2a547fc..05b0696ff 100644 --- a/go/client.go +++ b/go/client.go @@ -631,6 +631,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools req.Provider = config.Provider + req.EnableSessionTelemetry = config.EnableSessionTelemetry req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers @@ -790,6 +791,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider + req.EnableSessionTelemetry = config.EnableSessionTelemetry req.ModelCapabilities = config.ModelCapabilities req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools diff --git a/go/client_test.go b/go/client_test.go index a2dccab33..28b44086e 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -990,6 +990,35 @@ func TestResumeSessionRequest_ContinuePendingWork(t *testing.T) { }) } +func TestCreateSessionRequest_EnableSessionTelemetry(t *testing.T) { + t.Run("forwards enableSessionTelemetry when false", func(t *testing.T) { + req := createSessionRequest{ + EnableSessionTelemetry: 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["enableSessionTelemetry"] != false { + t.Errorf("Expected enableSessionTelemetry to be false, got %v", m["enableSessionTelemetry"]) + } + }) + + t.Run("omits enableSessionTelemetry when not set", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableSessionTelemetry"]; ok { + t.Error("Expected enableSessionTelemetry to be omitted when not set") + } + }) +} + func TestCreateSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { t.Run("defaults to true when nil", func(t *testing.T) { req := createSessionRequest{ @@ -1026,6 +1055,36 @@ func TestCreateSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { }) } +func TestResumeSessionRequest_EnableSessionTelemetry(t *testing.T) { + t.Run("forwards enableSessionTelemetry when false", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + EnableSessionTelemetry: 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["enableSessionTelemetry"] != false { + t.Errorf("Expected enableSessionTelemetry to be false, got %v", m["enableSessionTelemetry"]) + } + }) + + t.Run("omits enableSessionTelemetry when not set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableSessionTelemetry"]; ok { + t.Error("Expected enableSessionTelemetry to be omitted when not set") + } + }) +} + func TestResumeSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { t.Run("defaults to true when nil", func(t *testing.T) { req := resumeSessionRequest{ diff --git a/go/types.go b/go/types.go index 4cce207f5..161b798d6 100644 --- a/go/types.go +++ b/go/types.go @@ -575,6 +575,13 @@ type SessionConfig struct { IncludeSubAgentStreamingEvents *bool // Provider configures a custom model provider (BYOK) Provider *ProviderConfig + // EnableSessionTelemetry enables or disables internal session telemetry for this session. + // When false, disables session telemetry. When nil (the default) or true, + // telemetry is enabled for GitHub-authenticated sessions. When a custom + // Provider (BYOK) is configured, session telemetry is always disabled + // regardless of this setting. This is independent of the OpenTelemetry + // configuration in ClientOptions.Telemetry. + EnableSessionTelemetry *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -765,6 +772,13 @@ type ResumeSessionConfig struct { ExcludedTools []string // Provider configures a custom model provider Provider *ProviderConfig + // EnableSessionTelemetry enables or disables internal session telemetry for this session. + // When false, disables session telemetry. When nil (the default) or true, + // telemetry is enabled for GitHub-authenticated sessions. When a custom + // Provider (BYOK) is configured, session telemetry is always disabled + // regardless of this setting. This is independent of the OpenTelemetry + // configuration in ClientOptions.Telemetry. + EnableSessionTelemetry *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -1043,6 +1057,7 @@ type createSessionRequest struct { AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` + EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` @@ -1092,6 +1107,7 @@ type resumeSessionRequest struct { AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` + EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9c6494198..bcbb07064 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -802,6 +802,7 @@ export class CopilotClient { availableTools: config.availableTools, excludedTools: config.excludedTools, provider: config.provider ? toWireProviderConfig(config.provider) : undefined, + enableSessionTelemetry: config.enableSessionTelemetry, modelCapabilities: config.modelCapabilities, requestPermission: true, requestUserInput: !!config.onUserInputRequest, @@ -933,6 +934,7 @@ export class CopilotClient { systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, + enableSessionTelemetry: config.enableSessionTelemetry, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c7c6c8622..a5a621c73 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1283,6 +1283,16 @@ export interface SessionConfig { */ provider?: ProviderConfig; + /** + * Enables or disables internal session telemetry for this session. + * When `false`, disables session telemetry. When omitted (the default) or `true`, + * telemetry is enabled for GitHub-authenticated sessions. + * When a custom {@link provider} (BYOK) is configured, session telemetry is always + * disabled regardless of this setting. + * This is independent of the OpenTelemetry configuration in {@link CopilotClientOptions.telemetry}. + */ + enableSessionTelemetry?: boolean; + /** * Handler for permission requests from the server. * When provided, the server will call this handler to request permission for operations. @@ -1425,6 +1435,7 @@ export type ResumeSessionConfig = Pick< | "availableTools" | "excludedTools" | "provider" + | "enableSessionTelemetry" | "modelCapabilities" | "streaming" | "includeSubAgentStreamingEvents" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b2fe998ee..7328ebc1e 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -98,6 +98,47 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("forwards enableSessionTelemetry in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + enableSessionTelemetry: false, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ enableSessionTelemetry: false }) + ); + }); + + it("forwards enableSessionTelemetry in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + enableSessionTelemetry: false, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ enableSessionTelemetry: false, sessionId: session.sessionId }) + ); + spy.mockRestore(); + }); + it("defaults includeSubAgentStreamingEvents to true in session.create when not specified", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 70b70bc9b..f0098b58d 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1302,6 +1302,7 @@ async def create_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + enable_session_telemetry: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -1350,6 +1351,12 @@ async def create_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. + enable_session_telemetry: Enables or disables internal session telemetry + for this session. When False, disables session telemetry. When omitted + or True, telemetry is enabled for GitHub-authenticated sessions. When + a custom provider (BYOK) is configured, session telemetry is always + disabled regardless of this setting. This is independent of the client + OpenTelemetry configuration. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -1484,6 +1491,9 @@ async def create_session( if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) + if enable_session_telemetry is not None: + payload["enableSessionTelemetry"] = enable_session_telemetry + # Add model capabilities override if provided if model_capabilities: payload["modelCapabilities"] = _capabilities_to_dict(model_capabilities) @@ -1644,6 +1654,7 @@ async def resume_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + enable_session_telemetry: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -1693,6 +1704,12 @@ async def resume_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. + enable_session_telemetry: Enables or disables internal session telemetry + for this session. When False, disables session telemetry. When omitted + or True, telemetry is enabled for GitHub-authenticated sessions. When + a custom provider (BYOK) is configured, session telemetry is always + disabled regardless of this setting. This is independent of the client + OpenTelemetry configuration. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -1789,6 +1806,8 @@ async def resume_session( payload["excludedTools"] = excluded_tools if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) + if enable_session_telemetry is not None: + payload["enableSessionTelemetry"] = enable_session_telemetry if model_capabilities: payload["modelCapabilities"] = _capabilities_to_dict(model_capabilities) if streaming is not None: diff --git a/python/copilot/session.py b/python/copilot/session.py index 8a8021e19..86c5b8443 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -888,6 +888,12 @@ class SessionConfig(TypedDict, total=False): working_directory: str # Custom provider configuration (BYOK - Bring Your Own Key) provider: ProviderConfig + # Enables or disables internal session telemetry for this session. When False, + # disables session telemetry. When omitted (the default) or True, telemetry is enabled for + # GitHub-authenticated sessions. When a custom provider (BYOK) is configured, + # session telemetry is always disabled regardless of this setting. + # This is independent of the client OpenTelemetry configuration. + enable_session_telemetry: bool # Enable streaming of assistant message and reasoning chunks # When True, assistant.message_delta and assistant.reasoning_delta events # with delta_content are sent as the response is generated @@ -956,6 +962,12 @@ class ResumeSessionConfig(TypedDict, total=False): # registered via tools=. Ignored if available_tools is set. excluded_tools: list[str] provider: ProviderConfig + # Enables or disables internal session telemetry for this session. When False, + # disables session telemetry. When omitted (the default) or True, telemetry is enabled for + # GitHub-authenticated sessions. When a custom provider (BYOK) is configured, + # session telemetry is always disabled regardless of this setting. + # This is independent of the client OpenTelemetry configuration. + enable_session_telemetry: bool # Reasoning effort level for models that support it. reasoning_effort: ReasoningEffort on_permission_request: _PermissionHandlerFn diff --git a/python/e2e/test_tool_results_e2e.py b/python/e2e/test_tool_results_e2e.py index 3e54a3abf..b7b05b7af 100644 --- a/python/e2e/test_tool_results_e2e.py +++ b/python/e2e/test_tool_results_e2e.py @@ -106,6 +106,8 @@ def analyze_code(params: AnalyzeParams, invocation: ToolInvocation) -> ToolResul async def test_should_handle_tool_result_with_rejected_resulttype(self, ctx: E2ETestContext): tool_handler_called = False tool_complete_future: asyncio.Future = asyncio.get_event_loop().create_future() + idle_future: asyncio.Future = asyncio.get_event_loop().create_future() + tool_complete_seen = False @define_tool("deploy_service", description="Deploys a service") def deploy_service(invocation: ToolInvocation) -> ToolResult: @@ -124,8 +126,15 @@ def deploy_service(invocation: ToolInvocation) -> ToolResult: ) def on_event(event): - if event.type.value == "tool.execution_complete" and not tool_complete_future.done(): - tool_complete_future.set_result(event) + nonlocal tool_complete_seen + if event.type.value == "tool.execution_complete": + tool_complete_seen = True + if not tool_complete_future.done(): + tool_complete_future.set_result(event) + elif ( + event.type.value == "session.idle" and tool_complete_seen and not idle_future.done() + ): + idle_future.set_result(event) unsubscribe = session.on(on_event) try: @@ -147,14 +156,6 @@ def on_event(event): assert "Deployment rejected" in (error_msg or "") # Session should reach idle - idle_future: asyncio.Future = asyncio.get_event_loop().create_future() - session.on( - lambda e: ( - idle_future.set_result(e) - if e.type.value == "session.idle" and not idle_future.done() - else None - ) - ) await asyncio.wait_for(idle_future, timeout=30.0) finally: unsubscribe() diff --git a/python/test_client.py b/python/test_client.py index a890ca12e..26de29287 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -543,6 +543,57 @@ async def mock_request(method, params): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_session_forwards_enable_session_telemetry(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + enable_session_telemetry=False, + ) + assert captured["session.create"]["enableSessionTelemetry"] is False + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_forwards_enable_session_telemetry(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + enable_session_telemetry=False, + ) + assert captured["session.resume"]["enableSessionTelemetry"] is False + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_session_forwards_provider_headers(self): client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index d47d9f841..3831e02d6 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1042,6 +1042,15 @@ pub struct SessionConfig { /// routing. #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + /// Enables or disables internal session telemetry for this session. + /// + /// When `Some(false)`, disables session telemetry. When `None` or + /// `Some(true)`, telemetry is enabled for GitHub-authenticated sessions. + /// When a custom [`provider`](Self::provider) is configured, session + /// telemetry is always disabled regardless of this setting. This is + /// independent of [`ClientOptions::telemetry`](crate::ClientOptions::telemetry). + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_session_telemetry: Option, /// Per-property overrides for model capabilities, deep-merged over /// runtime defaults. #[serde(skip_serializing_if = "Option::is_none")] @@ -1122,6 +1131,7 @@ impl std::fmt::Debug for SessionConfig { .field("agent", &self.agent) .field("infinite_sessions", &self.infinite_sessions) .field("provider", &self.provider) + .field("enable_session_telemetry", &self.enable_session_telemetry) .field("model_capabilities", &self.model_capabilities) .field("config_dir", &self.config_dir) .field("working_directory", &self.working_directory) @@ -1181,6 +1191,7 @@ impl Default for SessionConfig { agent: None, infinite_sessions: None, provider: None, + enable_session_telemetry: None, model_capabilities: None, config_dir: None, working_directory: None, @@ -1445,6 +1456,14 @@ impl SessionConfig { self } + /// Enable or disable internal session telemetry. + /// + /// See [`Self::enable_session_telemetry`] for default and BYOK behavior. + pub fn with_enable_session_telemetry(mut self, enable: bool) -> Self { + self.enable_session_telemetry = Some(enable); + self + } + /// Set per-property overrides for model capabilities. pub fn with_model_capabilities( mut self, @@ -1560,6 +1579,15 @@ pub struct ResumeSessionConfig { /// Re-supply BYOK provider configuration on resume. #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + /// Enables or disables internal session telemetry for this session. + /// + /// When `Some(false)`, disables session telemetry. When `None` or + /// `Some(true)`, telemetry is enabled for GitHub-authenticated sessions. + /// When a custom [`provider`](Self::provider) is configured, session + /// telemetry is always disabled regardless of this setting. This is + /// independent of [`ClientOptions::telemetry`](crate::ClientOptions::telemetry). + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_session_telemetry: Option, /// Per-property model capability overrides on resume. #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, @@ -1635,6 +1663,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("agent", &self.agent) .field("infinite_sessions", &self.infinite_sessions) .field("provider", &self.provider) + .field("enable_session_telemetry", &self.enable_session_telemetry) .field("model_capabilities", &self.model_capabilities) .field("config_dir", &self.config_dir) .field("working_directory", &self.working_directory) @@ -1692,6 +1721,7 @@ impl ResumeSessionConfig { agent: None, infinite_sessions: None, provider: None, + enable_session_telemetry: None, model_capabilities: None, config_dir: None, working_directory: None, @@ -1919,6 +1949,14 @@ impl ResumeSessionConfig { self } + /// Enable or disable internal session telemetry on resume. + /// + /// See [`Self::enable_session_telemetry`] for default and BYOK behavior. + pub fn with_enable_session_telemetry(mut self, enable: bool) -> Self { + self.enable_session_telemetry = Some(enable); + self + } + /// Set per-property model capability overrides on resume. pub fn with_model_capabilities( mut self, @@ -3073,6 +3111,7 @@ mod tests { .with_config_dir(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") + .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(false); assert_eq!(cfg.session_id.as_ref().map(|s| s.as_str()), Some("sess-1")); @@ -3105,6 +3144,7 @@ mod tests { assert_eq!(cfg.config_dir, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); + assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); } @@ -3127,6 +3167,7 @@ mod tests { .with_config_dir(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") + .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(true) .with_disable_resume(true) .with_continue_pending_work(true); @@ -3159,6 +3200,7 @@ mod tests { assert_eq!(cfg.config_dir, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); + assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(true)); assert_eq!(cfg.disable_resume, Some(true)); assert_eq!(cfg.continue_pending_work, Some(true)); diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 1f9873879..4e05960e7 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -2477,6 +2477,7 @@ fn session_config_serializes_bucket_b_fields() { cfg.working_directory = Some(PathBuf::from("/tmp/work")); cfg.github_token = Some("ghs_secret".to_string()); cfg.include_sub_agent_streaming_events = Some(false); + cfg.enable_session_telemetry = Some(false); cfg }; let json = serde_json::to_value(&cfg).unwrap(); @@ -2485,6 +2486,7 @@ fn session_config_serializes_bucket_b_fields() { assert_eq!(json["workingDirectory"], "/tmp/work"); assert_eq!(json["gitHubToken"], "ghs_secret"); assert_eq!(json["includeSubAgentStreamingEvents"], false); + assert_eq!(json["enableSessionTelemetry"], false); // Debug never leaks the token. let debug = format!("{cfg:?}"); @@ -2495,6 +2497,7 @@ fn session_config_serializes_bucket_b_fields() { let empty = serde_json::to_value(SessionConfig::default()).unwrap(); assert!(empty.get("sessionId").is_none()); assert!(empty.get("gitHubToken").is_none()); + assert!(empty.get("enableSessionTelemetry").is_none()); } #[test] @@ -2508,12 +2511,14 @@ fn resume_session_config_serializes_bucket_b_fields() { cfg.config_dir = Some(PathBuf::from("/tmp/cfg")); cfg.github_token = Some("ghs_secret".to_string()); cfg.include_sub_agent_streaming_events = Some(true); + cfg.enable_session_telemetry = Some(false); let json = serde_json::to_value(&cfg).unwrap(); assert_eq!(json["sessionId"], "sess-1"); assert_eq!(json["workingDirectory"], "/tmp/work"); assert_eq!(json["configDir"], "/tmp/cfg"); assert_eq!(json["gitHubToken"], "ghs_secret"); assert_eq!(json["includeSubAgentStreamingEvents"], true); + assert_eq!(json["enableSessionTelemetry"], false); let debug = format!("{cfg:?}"); assert!(!debug.contains("ghs_secret"), "leaked token: {debug}");