From d2f5697d702a90191dc8a6e153ed9cc000d832d6 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:22 -0700 Subject: [PATCH 1/8] codegen: add notification flag to RpcMethod metadata Adds an optional otification marker to the shared RpcMethod interface so the per-language generators can emit notification-style dispatch (no JSON-RPC reply) for void clientGlobal methods such as gitHubTelemetry.event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/codegen/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index c63f9732c4..9ab335b05f 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -383,6 +383,7 @@ export interface RpcMethod { stability?: string; visibility?: string; deprecated?: boolean; + notification?: boolean; } export function getRpcSchemaTypeName(schema: JSONSchema7 | null | undefined, fallback: string): string { From 8561b0658e6cd9c2d349b078065675a52cdb3288 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:31 -0700 Subject: [PATCH 2/8] nodejs: add GitHub telemetry redirection support Regenerates the TypeScript RPC types for the experimental gitHubTelemetry.event clientGlobal notification and wires an onGitHubTelemetry callback on the client. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 35 ++++++--- nodejs/src/generated/rpc.ts | 136 ++++++++++++++++++++++++++++++++++ nodejs/src/index.ts | 3 + nodejs/src/types.ts | 18 +++++ nodejs/test/client.test.ts | 131 ++++++++++++++++++++++++++++++++ scripts/codegen/typescript.ts | 19 ++++- 6 files changed, 329 insertions(+), 13 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 53686a6ca3..761211423f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -33,7 +33,7 @@ import { registerClientGlobalApiHandlers, registerClientSessionApiHandlers, } from "./generated/rpc.js"; -import type { OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; +import type { GitHubTelemetryNotification, OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; @@ -514,7 +514,8 @@ export class CopilotClient { /** Connection-level session filesystem config, set via constructor option. */ private sessionFsConfig: SessionFsConfig | null = null; private requestHandler: CopilotRequestHandler | null = null; - private llmInferenceHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; + private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void; + private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; /** * Typed server-scoped RPC methods. @@ -634,7 +635,8 @@ export class CopilotClient { this.onGetTraceContext = options.onGetTraceContext; this.sessionFsConfig = options.sessionFs ?? null; this.requestHandler = options.requestHandler ?? null; - this.setupLlmInference(); + this.onGitHubTelemetry = options.onGitHubTelemetry; + this.setupClientGlobalHandlers(); const effectiveEnv = options.env ?? process.env; this.resolvedEnv = effectiveEnv; @@ -751,19 +753,26 @@ export class CopilotClient { session.clientSessionApis.sessionFs = createSessionFsAdapter(provider); } - private setupLlmInference(): void { - if (!this.requestHandler) { - return; - } - this.llmInferenceHandlers = { - llmInference: createCopilotRequestAdapter(this.requestHandler, () => { + private setupClientGlobalHandlers(): void { + const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; + if (this.requestHandler) { + handlers.llmInference = createCopilotRequestAdapter(this.requestHandler, () => { if (!this.connection) { return undefined; } this._rpc ??= createServerRpc(this.connection); return this._rpc; - }), - }; + }); + } + if (this.onGitHubTelemetry) { + const onGitHubTelemetry = this.onGitHubTelemetry; + handlers.gitHubTelemetry = { + event: async (notification) => { + onGitHubTelemetry(notification); + }, + }; + } + this.clientGlobalHandlers = handlers; } /** @@ -1422,6 +1431,7 @@ export class CopilotClient { workingDirectory: config.workingDirectory, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, + enableGitHubTelemetryRedirection: this.onGitHubTelemetry != null, mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", @@ -1628,6 +1638,7 @@ export class CopilotClient { enableSkills: config.enableSkills, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, + enableGitHubTelemetryRedirection: this.onGitHubTelemetry != null, mcpServers: toWireMcpServers(config.mcpServers), mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", @@ -2545,7 +2556,7 @@ export class CopilotClient { // Register client *global* API handlers (e.g. LLM inference) on the // same connection. These methods carry no implicit sessionId dispatch // — the runtime calls into a single handler for the whole connection. - registerClientGlobalApiHandlers(this.connection, this.llmInferenceHandlers); + registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers); this.connection.onClose(() => { this.state = "disconnected"; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 38f77412d9..8c056de8d3 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -13729,6 +13729,125 @@ export interface WorkspacesSaveLargePasteResult { sizeBytes: number; } | null; } +/** + * Client environment metadata describing the process that produced a telemetry event. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryClientInfo". + */ +/** @experimental */ +export interface GitHubTelemetryClientInfo { + /** + * Copilot CLI version string. + */ + cli_version: string; + /** + * Operating system platform (e.g. darwin, linux, win32). + */ + os_platform: string; + /** + * Operating system version string. + */ + os_version: string; + /** + * Operating system architecture (e.g. arm64, x64). + */ + os_arch: string; + /** + * Node.js runtime version string. + */ + node_version: string; + /** + * Copilot subscription plan, when known. + */ + copilot_plan?: string; + /** + * Type of client. + */ + client_type?: string; + /** + * Name of the client application. + */ + client_name?: string; + /** + * Whether the user is a GitHub/Microsoft staff member. + */ + is_staff?: boolean; + /** + * Stable machine identifier for the device. + */ + dev_device_id?: string; +} +/** + * A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryEvent". + */ +/** @experimental */ +export interface GitHubTelemetryEvent { + /** + * Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + */ + kind: string; + /** + * Timestamp when the event was created (ISO 8601 format). + */ + created_at?: string; + /** + * Reference to the model call that produced this event. + */ + model_call_id?: string; + /** + * String-valued properties as a map from key to value. + */ + properties: { + [k: string]: string | undefined; + }; + /** + * Numeric metrics as a map from key to value. + */ + metrics: { + [k: string]: number | undefined; + }; + /** + * Experiment assignment context. + */ + exp_assignment_context?: string; + /** + * Feature flags enabled for this session, as a map from flag to value. + */ + features?: { + [k: string]: string | undefined; + }; + /** + * Session identifier the event belongs to. + */ + session_id?: string; + /** + * Copilot tracking ID for user-level attribution. + */ + copilot_tracking_id?: string; + client?: GitHubTelemetryClientInfo; +} +/** + * Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "GitHubTelemetryNotification". + */ +/** @experimental */ +export interface GitHubTelemetryNotification { + /** + * Session the telemetry event belongs to. + */ + sessionId: string; + /** + * Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + */ + restricted: boolean; + event: GitHubTelemetryEvent; +} /** * Standard MCP CallToolResult * @@ -16155,9 +16274,21 @@ export interface LlmInferenceHandler { httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest): Promise; } +/** Handler for `gitHubTelemetry` client global API methods. */ +/** @experimental */ +export interface GitHubTelemetryHandler { + /** + * Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session. + * + * @param params Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + */ + event(params: GitHubTelemetryNotification): Promise; +} + /** All client global API handler groups. */ export interface ClientGlobalApiHandlers { llmInference?: LlmInferenceHandler; + gitHubTelemetry?: GitHubTelemetryHandler; } /** @@ -16181,4 +16312,9 @@ export function registerClientGlobalApiHandlers( if (!handler) throw new Error("No llmInference client-global handler registered"); return handler.httpRequestChunk(params); }); + connection.onNotification("gitHubTelemetry.event", async (params: GitHubTelemetryNotification) => { + const handler = handlers.gitHubTelemetry; + if (!handler) return; + await handler.event(params); + }); } diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index eebf9add5e..e05b33c158 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -76,6 +76,9 @@ export type { ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, + GitHubTelemetryNotification, + GitHubTelemetryEvent, + GitHubTelemetryClientInfo, InfiniteSessionConfig, LargeToolOutputConfig, MemoryConfiguration, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e354bd8218..a050c3abd6 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -16,12 +16,18 @@ import type { } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; import type { + GitHubTelemetryNotification, ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, } from "./generated/rpc.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; +export type { + GitHubTelemetryNotification, + GitHubTelemetryEvent, + GitHubTelemetryClientInfo, +} from "./generated/rpc.js"; export type { ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, @@ -338,6 +344,18 @@ export interface CopilotClientOptions { */ requestHandler?: CopilotRequestHandler; + /** + * Experimental. Receives GitHub telemetry events the runtime forwards to + * this connection. When set, the client opts each session it creates or + * resumes into telemetry redirection and dispatches each + * `gitHubTelemetry.event` notification to this connection-global handler; + * each {@link GitHubTelemetryNotification} carries its originating + * `sessionId`. + * + * @experimental + */ + onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void; + /** * Server-wide idle timeout for sessions in seconds. * Sessions without activity for this duration are automatically cleaned up. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96d7da30cf..cd07f902ca 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -7,6 +7,7 @@ import { CopilotClient, createCanvas, RuntimeConnection, + type GitHubTelemetryNotification, type ModelInfo, } from "../src/index.js"; import { CopilotSession } from "../src/session.js"; @@ -188,6 +189,136 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); + it("opts into GitHub telemetry redirection when onGitHubTelemetry is provided", async () => { + const client = new CopilotClient({ onGitHubTelemetry: () => {} }); + 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 }); + await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); + + 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.enableGitHubTelemetryRedirection).toBe(true); + expect(resumePayload.enableGitHubTelemetryRedirection).toBe(true); + }); + + it("does not opt into GitHub telemetry redirection without a handler", 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 }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ onPermissionRequest: approveAll }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + expect(createPayload.enableGitHubTelemetryRedirection).toBe(false); + }); + + it("dispatches a real gitHubTelemetry.event wire notification to the handler", async () => { + const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = await import( + "vscode-jsonrpc/node.js" + ); + const { registerClientGlobalApiHandlers } = await import("../src/generated/rpc.js"); + + const clientToServer = new PassThrough(); + const serverToClient = new PassThrough(); + + const clientConn = createMessageConnection( + new StreamMessageReader(serverToClient), + new StreamMessageWriter(clientToServer) + ); + const serverConn = createMessageConnection( + new StreamMessageReader(clientToServer), + new StreamMessageWriter(serverToClient) + ); + onTestFinished(() => { + clientConn.dispose(); + serverConn.dispose(); + }); + + const received: GitHubTelemetryNotification[] = []; + let resolveReceived: () => void; + const got = new Promise((resolve) => { + resolveReceived = resolve; + }); + + registerClientGlobalApiHandlers(clientConn, { + gitHubTelemetry: { + event: async (notification) => { + received.push(notification); + resolveReceived(); + }, + }, + }); + + clientConn.listen(); + serverConn.listen(); + + const notification: GitHubTelemetryNotification = { + sessionId: "session-1", + restricted: false, + event: { + kind: "tool_call_executed", + properties: { tool: "shell" }, + metrics: { duration_ms: 42 }, + }, + }; + + // Send as a real JSON-RPC notification (no id). A regression that wires + // this method up as a request handler would never fire and this await + // would hang. + await serverConn.sendNotification("gitHubTelemetry.event", notification); + await got; + + expect(received).toEqual([notification]); + }); + + it("registers no gitHubTelemetry handler when onGitHubTelemetry is omitted", () => { + const client = new CopilotClient(); + onTestFinished(() => client.forceStop()); + + const handlers = (client as any).clientGlobalHandlers; + expect(handlers.gitHubTelemetry).toBeUndefined(); + }); + + it("forwards gitHubTelemetry events to the onGitHubTelemetry handler", () => { + const received: GitHubTelemetryNotification[] = []; + const client = new CopilotClient({ onGitHubTelemetry: (n) => received.push(n) }); + onTestFinished(() => client.forceStop()); + + const handlers = (client as any).clientGlobalHandlers; + expect(handlers.gitHubTelemetry).toBeDefined(); + + const notification: GitHubTelemetryNotification = { + sessionId: "session-1", + restricted: false, + event: { kind: "tool_call_executed", properties: {}, metrics: {} }, + }; + handlers.gitHubTelemetry.event(notification); + expect(received).toEqual([notification]); + }); + it("forwards expAssignments in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 1303a4979c..497c909ea5 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -1011,7 +1011,24 @@ function emitClientGlobalApiRegistration(clientSchema: Record): const pType = paramsTypeName(method); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); - if (hasParams) { + if (method.notification) { + // Notification methods carry no response; the server dispatches + // them via `sendNotification`, which only fires `onNotification` + // handlers (an `onRequest` handler would never be invoked). + if (hasParams) { + lines.push(` connection.onNotification("${method.rpcMethod}", async (params: ${pType}) => {`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) return;`); + lines.push(` await handler.${name}(params);`); + lines.push(` });`); + } else { + lines.push(` connection.onNotification("${method.rpcMethod}", async () => {`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) return;`); + lines.push(` await handler.${name}();`); + lines.push(` });`); + } + } else if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) throw new Error("No ${groupName} client-global handler registered");`); From fc5638a6a2cdfa000f4ebf652815a8db3b6a89f0 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:38 -0700 Subject: [PATCH 3/8] dotnet: add GitHub telemetry redirection support Regenerates the C# RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an OnGitHubTelemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. The option is marked [Experimental] and [EditorBrowsable(Never)] to keep it unadvertised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 42 ++- dotnet/src/Generated/Rpc.cs | 128 +++++++ dotnet/src/Types.cs | 9 + dotnet/test/Unit/GitHubTelemetryTests.cs | 430 +++++++++++++++++++++++ 4 files changed, 601 insertions(+), 8 deletions(-) create mode 100644 dotnet/test/Unit/GitHubTelemetryTests.cs diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a67eb96817..aaff9005fa 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions; using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Net.Sockets; using System.Runtime.ExceptionServices; @@ -1033,7 +1034,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, - ExpAssignments: config.ExpAssignments); + ExpAssignments: config.ExpAssignments, + EnableGitHubTelemetryRedirection: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -1235,7 +1237,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, - ExpAssignments: config.ExpAssignments); + ExpAssignments: config.ExpAssignments, + EnableGitHubTelemetryRedirection: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -1708,21 +1711,24 @@ await Rpc.SessionFs.SetProviderAsync( } /// - /// Builds the client-global RPC handler bag at construction time. Currently - /// only the LLM inference provider adapter is registered; returns null when no + /// Builds the client-global RPC handler bag at construction time. Registers + /// the LLM inference provider adapter and/or the GitHub telemetry adapter + /// depending on which options are configured; returns null when no /// client-global API is configured so the registration is skipped entirely. /// private ClientGlobalApiHandlers? BuildClientGlobalApis() { var handler = _options.RequestHandler; - if (handler is null) + var onGitHubTelemetry = _options.OnGitHubTelemetry; + if (handler is null && onGitHubTelemetry is null) { return null; } return new ClientGlobalApiHandlers { - LlmInference = new LlmInferenceAdapter(handler, () => _serverRpc), + LlmInference = handler is null ? null : new LlmInferenceAdapter(handler, () => _serverRpc), + GitHubTelemetry = onGitHubTelemetry is null ? null : new GitHubTelemetryAdapter(onGitHubTelemetry), }; } @@ -2476,7 +2482,8 @@ internal record CreateSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, - [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null); + [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + bool? EnableGitHubTelemetryRedirection = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -2572,7 +2579,8 @@ internal record ResumeSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, - [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null); + [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + bool? EnableGitHubTelemetryRedirection = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( @@ -2690,3 +2698,21 @@ public sealed class ToolResultAIContent(ToolResultObject toolResult) : AIContent /// public ToolResultObject Result => toolResult; } + +/// +/// Bridges the generated client-global handler to +/// the public OnGitHubTelemetry callback, forwarding the generated +/// payload unchanged. +/// +[Experimental(Diagnostics.Experimental)] +internal sealed class GitHubTelemetryAdapter(Action callback) : Rpc.IGitHubTelemetryHandler +{ + private readonly Action _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + + public Task EventAsync(Rpc.GitHubTelemetryNotification request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + _callback(request); + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3a9fcf9cde..559a1f11f4 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -10891,6 +10891,113 @@ public sealed class LlmInferenceHttpRequestChunkRequest public string RequestId { get; set; } = string.Empty; } +/// Client environment metadata describing the process that produced a telemetry event. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryClientInfo +{ + /// Copilot CLI version string. + [JsonPropertyName("cli_version")] + public string CliVersion { get; set; } = string.Empty; + + /// Name of the client application. + [JsonPropertyName("client_name")] + public string? ClientName { get; set; } + + /// Type of client. + [JsonPropertyName("client_type")] + public string? ClientType { get; set; } + + /// Copilot subscription plan, when known. + [JsonPropertyName("copilot_plan")] + public string? CopilotPlan { get; set; } + + /// Stable machine identifier for the device. + [JsonPropertyName("dev_device_id")] + public string? DevDeviceId { get; set; } + + /// Whether the user is a GitHub/Microsoft staff member. + [JsonPropertyName("is_staff")] + public bool? IsStaff { get; set; } + + /// Node.js runtime version string. + [JsonPropertyName("node_version")] + public string NodeVersion { get; set; } = string.Empty; + + /// Operating system architecture (e.g. arm64, x64). + [JsonPropertyName("os_arch")] + public string OsArch { get; set; } = string.Empty; + + /// Operating system platform (e.g. darwin, linux, win32). + [JsonPropertyName("os_platform")] + public string OsPlatform { get; set; } = string.Empty; + + /// Operating system version string. + [JsonPropertyName("os_version")] + public string OsVersion { get; set; } = string.Empty; +} + +/// A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryEvent +{ + /// Client environment metadata. + [JsonPropertyName("client")] + public GitHubTelemetryClientInfo? Client { get; set; } + + /// Copilot tracking ID for user-level attribution. + [JsonPropertyName("copilot_tracking_id")] + public string? CopilotTrackingId { get; set; } + + /// Timestamp when the event was created (ISO 8601 format). + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + /// Experiment assignment context. + [JsonPropertyName("exp_assignment_context")] + public string? ExpAssignmentContext { get; set; } + + /// Feature flags enabled for this session, as a map from flag to value. + [JsonPropertyName("features")] + public IDictionary? Features { get; set; } + + /// Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + [JsonPropertyName("kind")] + public string Kind { get; set; } = string.Empty; + + /// Numeric metrics as a map from key to value. + [JsonPropertyName("metrics")] + public IDictionary Metrics { get => field ??= new Dictionary(); set; } + + /// Reference to the model call that produced this event. + [JsonPropertyName("model_call_id")] + public string? ModelCallId { get; set; } + + /// String-valued properties as a map from key to value. + [JsonPropertyName("properties")] + public IDictionary Properties { get => field ??= new Dictionary(); set; } + + /// Session identifier the event belongs to. + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } +} + +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. +[Experimental(Diagnostics.Experimental)] +public sealed class GitHubTelemetryNotification +{ + /// The telemetry event, in the runtime's native GitHub-shaped telemetry format. + [JsonPropertyName("event")] + public GitHubTelemetryEvent Event { get => field ??= new(); set; } + + /// Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + [JsonPropertyName("restricted")] + public bool Restricted { get; set; } + + /// Session the telemetry event belongs to. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Model capability category for grouping in the model picker. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -20914,11 +21021,24 @@ public interface ILlmInferenceHandler Task HttpRequestChunkAsync(LlmInferenceHttpRequestChunkRequest request, CancellationToken cancellationToken = default); } +/// Handles `gitHubTelemetry` client global API methods. +[Experimental(Diagnostics.Experimental)] +public interface IGitHubTelemetryHandler +{ + /// Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session. + /// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. + /// The to monitor for cancellation requests. The default is . + Task EventAsync(GitHubTelemetryNotification request, CancellationToken cancellationToken = default); +} + /// Provides all client global API handler groups for a connection. public sealed class ClientGlobalApiHandlers { /// Optional handler for LlmInference client global API methods. public ILlmInferenceHandler? LlmInference { get; set; } + + /// Optional handler for GitHubTelemetry client global API methods. + public IGitHubTelemetryHandler? GitHubTelemetry { get; set; } } /// Registers client global API handlers on a JSON-RPC connection. @@ -20942,6 +21062,11 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH var handler = handlers.LlmInference ?? throw new InvalidOperationException("No llmInference client-global handler registered"); return await handler.HttpRequestChunkAsync(request, cancellationToken); }), singleObjectParam: true); + rpc.SetLocalRpcMethod("gitHubTelemetry.event", (Func)(async (request, cancellationToken) => + { + var handler = handlers.GitHubTelemetry ?? throw new InvalidOperationException("No gitHubTelemetry client-global handler registered"); + await handler.EventAsync(request, cancellationToken); + }), singleObjectParam: true); } } @@ -21313,6 +21438,9 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(FolderTrustAddParams))] [JsonSerializable(typeof(FolderTrustCheckParams))] [JsonSerializable(typeof(FolderTrustCheckResult))] +[JsonSerializable(typeof(GitHubTelemetryClientInfo))] +[JsonSerializable(typeof(GitHubTelemetryEvent))] +[JsonSerializable(typeof(GitHubTelemetryNotification))] [JsonSerializable(typeof(HandlePendingToolCallRequest))] [JsonSerializable(typeof(HandlePendingToolCallResult))] [JsonSerializable(typeof(HistoryAbortManualCompactionResult))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 5ae9657813..aa69f2a069 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -281,6 +281,7 @@ private CopilotClientOptions(CopilotClientOptions? other) OnListModels = other.OnListModels; SessionFs = other.SessionFs; RequestHandler = other.RequestHandler; + OnGitHubTelemetry = other.OnGitHubTelemetry; SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds; EnableRemoteSessions = other.EnableRemoteSessions; Mode = other.Mode; @@ -378,6 +379,14 @@ private CopilotClientOptions(CopilotClientOptions? other) [Experimental(Diagnostics.Experimental)] public CopilotRequestHandler? RequestHandler { get; set; } + /// + /// Experimental. Receives GitHub telemetry events the runtime forwards to this + /// connection; setting a handler opts created/resumed sessions into redirection. + /// + [Experimental(Diagnostics.Experimental)] + [EditorBrowsable(EditorBrowsableState.Never)] + public Action? OnGitHubTelemetry { get; set; } + /// /// OpenTelemetry configuration for the runtime. /// When set to a non- instance, the runtime is started with OpenTelemetry instrumentation enabled. diff --git a/dotnet/test/Unit/GitHubTelemetryTests.cs b/dotnet/test/Unit/GitHubTelemetryTests.cs new file mode 100644 index 0000000000..465791f649 --- /dev/null +++ b/dotnet/test/Unit/GitHubTelemetryTests.cs @@ -0,0 +1,430 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Xunit; + +using GitHub.Copilot.Rpc; + +namespace GitHub.Copilot.Test.Unit; + +#pragma warning disable GHCP001 // GitHub telemetry redirection is experimental. + +public sealed class GitHubTelemetryTests +{ + [Fact] + public async Task CreateSession_Opts_Into_Redirection_When_Handler_Provided() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = _ => { }, + }); + await client.StartAsync(); + + await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); + + var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured."); + Assert.True(createParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag)); + Assert.True(flag.GetBoolean()); + } + + [Fact] + public async Task ResumeSession_Opts_Into_Redirection_When_Handler_Provided() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = _ => { }, + }); + await client.StartAsync(); + + await client.ResumeSessionAsync("session-1", new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); + + var resumeParams = server.LastResumeParams ?? throw new InvalidOperationException("session.resume was not captured."); + Assert.True(resumeParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag)); + Assert.True(flag.GetBoolean()); + } + + [Fact] + public async Task CreateSession_Does_Not_Opt_In_Without_Handler() + { + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + }); + await client.StartAsync(); + + await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); + + var createParams = server.LastCreateParams ?? throw new InvalidOperationException("session.create was not captured."); + var optedIn = createParams.TryGetProperty("enableGitHubTelemetryRedirection", out var flag) + && flag.ValueKind == JsonValueKind.True; + Assert.False(optedIn); + } + + [Fact] + public async Task GitHubTelemetry_Event_Is_Forwarded_To_OnGitHubTelemetry() + { + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = notification => received.TrySetResult(notification), + }); + await client.StartAsync(); + + await server.SendGitHubTelemetryEventAsync(new Dictionary + { + ["sessionId"] = "session-1", + ["restricted"] = false, + ["event"] = new Dictionary + { + ["kind"] = "tool_call_executed", + ["properties"] = new Dictionary { ["tool"] = "shell" }, + ["metrics"] = new Dictionary { ["duration_ms"] = 42 }, + ["session_id"] = "session-1", + }, + }); + + var notification = await received.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal("session-1", notification.SessionId); + Assert.False(notification.Restricted); + Assert.Equal("tool_call_executed", notification.Event.Kind); + Assert.Equal("shell", notification.Event.Properties["tool"]); + Assert.Equal(42, notification.Event.Metrics["duration_ms"]); + Assert.Equal("session-1", notification.Event.SessionId); + } + + [Fact] + public async Task GitHubTelemetry_Event_Maps_Restricted_And_ClientInfo() + { + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var server = await FakeTelemetryServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url), + OnGitHubTelemetry = notification => received.TrySetResult(notification), + }); + await client.StartAsync(); + + await server.SendGitHubTelemetryEventAsync(new Dictionary + { + ["sessionId"] = "session-2", + ["restricted"] = true, + ["event"] = new Dictionary + { + ["kind"] = "model_call", + ["properties"] = new Dictionary { ["model"] = "gpt-5" }, + ["metrics"] = new Dictionary { ["tokens"] = 128 }, + ["session_id"] = "session-2", + ["client"] = new Dictionary + { + ["cli_version"] = "1.2.3", + ["os_platform"] = "win32", + ["os_arch"] = "x64", + ["node_version"] = "20.0.0", + ["is_staff"] = false, + }, + }, + }); + + var notification = await received.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(notification.Restricted); + + var clientInfo = notification.Event.Client; + Assert.NotNull(clientInfo); + Assert.Equal("1.2.3", clientInfo!.CliVersion); + Assert.Equal("win32", clientInfo.OsPlatform); + Assert.Equal("x64", clientInfo.OsArch); + Assert.Equal("20.0.0", clientInfo.NodeVersion); + Assert.Equal(false, clientInfo.IsStaff); + } + + private sealed class FakeTelemetryServer : IAsyncDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task _serverTask; + + private FakeTelemetryServer(TcpListener listener) + { + _listener = listener; + _serverTask = RunAsync(); + } + + public string Url + { + get + { + var endpoint = (IPEndPoint)_listener.LocalEndpoint; + return $"http://127.0.0.1:{endpoint.Port}"; + } + } + + public JsonElement? LastCreateParams { get; private set; } + + public JsonElement? LastResumeParams { get; private set; } + + public static Task StartAsync() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return Task.FromResult(new FakeTelemetryServer(listener)); + } + + public async Task SendGitHubTelemetryEventAsync(Dictionary notificationParams) + { + var stream = await _connected.Task.WaitAsync(_cts.Token); + + // Send a genuine JSON-RPC notification (no "id"), exactly as the runtime + // does via sendNotification. This exercises the real notification dispatch + // path rather than masking it behind a request that carries an id. + await WriteMessageAsync(stream, new Dictionary + { + ["jsonrpc"] = "2.0", + ["method"] = "gitHubTelemetry.event", + ["params"] = notificationParams, + }, _cts.Token); + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + _listener.Stop(); + + try + { + await _serverTask; + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException or SocketException) + { + } + + _cts.Dispose(); + _writeLock.Dispose(); + } + + private async Task RunAsync() + { + using var tcpClient = await _listener.AcceptTcpClientAsync(_cts.Token); + using var stream = tcpClient.GetStream(); + _connected.TrySetResult(stream); + + while (!_cts.Token.IsCancellationRequested) + { + using var message = await ReadMessageAsync(stream, _cts.Token); + if (message is null) + { + return; + } + + // Inbound messages without a "method" are responses to our own + // server-initiated requests (e.g. session.* the SDK answers); the + // SDK never replies to the gitHubTelemetry.event notification. + if (!message.RootElement.TryGetProperty("method", out _)) + { + continue; + } + + await HandleRequestAsync(stream, message.RootElement, _cts.Token); + } + } + + private async Task HandleRequestAsync(Stream stream, JsonElement request, CancellationToken cancellationToken) + { + if (!request.TryGetProperty("id", out var idElement)) + { + return; + } + + var id = idElement.Clone(); + var method = request.GetProperty("method").GetString(); + + object? result = method switch + { + "connect" => new Dictionary + { + ["ok"] = true, + ["protocolVersion"] = 3, + ["version"] = "test", + }, + "session.create" => CaptureCreate(request), + "session.resume" => CaptureResume(request), + "session.send" => new Dictionary { ["messageId"] = "message-1" }, + "session.destroy" => new Dictionary(), + "runtime.shutdown" => new Dictionary(), + _ => throw new InvalidOperationException($"Unexpected RPC method '{method}'."), + }; + + await WriteMessageAsync(stream, new Dictionary + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = result, + }, cancellationToken); + } + + private Dictionary CaptureCreate(JsonElement request) + { + LastCreateParams = request.TryGetProperty("params", out var p) ? p.Clone() : null; + return SessionResult(LastCreateParams); + } + + private Dictionary CaptureResume(JsonElement request) + { + LastResumeParams = request.TryGetProperty("params", out var p) ? p.Clone() : null; + return SessionResult(LastResumeParams); + } + + private static Dictionary SessionResult(JsonElement? paramsElement) + { + string sessionId = "session-1"; + if (paramsElement is { ValueKind: JsonValueKind.Object } p + && p.TryGetProperty("sessionId", out var sidProp) + && sidProp.ValueKind == JsonValueKind.String + && sidProp.GetString() is string sid + && !string.IsNullOrEmpty(sid)) + { + sessionId = sid; + } + + return new Dictionary + { + ["sessionId"] = sessionId, + ["workspacePath"] = null, + ["capabilities"] = null, + }; + } + + private async Task WriteMessageAsync(Stream stream, object payload, CancellationToken cancellationToken) + { + using var bodyStream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(bodyStream)) + { + WriteJsonValue(writer, payload); + } + + var body = bodyStream.ToArray(); + var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(body, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + private static void WriteJsonValue(Utf8JsonWriter writer, object? value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string stringValue: + writer.WriteStringValue(stringValue); + break; + case bool boolValue: + writer.WriteBooleanValue(boolValue); + break; + case int intValue: + writer.WriteNumberValue(intValue); + break; + case long longValue: + writer.WriteNumberValue(longValue); + break; + case JsonElement jsonElement: + jsonElement.WriteTo(writer); + break; + case Dictionary dictionary: + writer.WriteStartObject(); + foreach (var (propertyName, propertyValue) in dictionary) + { + writer.WritePropertyName(propertyName); + WriteJsonValue(writer, propertyValue); + } + writer.WriteEndObject(); + break; + default: + throw new InvalidOperationException($"Unexpected JSON value type '{value.GetType().Name}'."); + } + } + + private static async Task ReadMessageAsync(Stream stream, CancellationToken cancellationToken) + { + var headerBytes = new List(); + while (true) + { + var value = await ReadByteAsync(stream, cancellationToken); + if (value < 0) + { + return null; + } + + headerBytes.Add((byte)value); + var count = headerBytes.Count; + if (count >= 4 && + headerBytes[count - 4] == '\r' && + headerBytes[count - 3] == '\n' && + headerBytes[count - 2] == '\r' && + headerBytes[count - 1] == '\n') + { + break; + } + } + + var header = Encoding.ASCII.GetString([.. headerBytes]); + var contentLength = header + .Split(["\r\n"], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(':', 2)) + .Where(parts => parts.Length == 2 && parts[0].Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + .Select(parts => int.Parse(parts[1].Trim(), System.Globalization.CultureInfo.InvariantCulture)) + .Single(); + + var body = new byte[contentLength]; + var offset = 0; + while (offset < body.Length) + { + var read = await stream.ReadAsync(body.AsMemory(offset, body.Length - offset), cancellationToken); + if (read == 0) + { + return null; + } + + offset += read; + } + + return JsonDocument.Parse(body); + } + + private static async Task ReadByteAsync(Stream stream, CancellationToken cancellationToken) + { + var buffer = new byte[1]; + var read = await stream.ReadAsync(buffer, cancellationToken); + return read == 0 ? -1 : buffer[0]; + } + } +} + +#pragma warning restore GHCP001 +#endif From ca4c483494e099b9e525f8aa3b5c58aa6223ea0b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:45 -0700 Subject: [PATCH 4/8] python: add GitHub telemetry redirection support Regenerates the Python RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an on_github_telemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/_jsonrpc.py | 44 ++++++- python/copilot/client.py | 62 ++++++++-- python/copilot/generated/rpc.py | 208 +++++++++++++++++++++++++++++++- python/test_client.py | 180 +++++++++++++++++++++++++++ scripts/codegen/python.ts | 14 +++ 5 files changed, 496 insertions(+), 12 deletions(-) diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index a58908d08d..5e799149e0 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -80,6 +80,7 @@ def __init__(self, process): self.pending_requests: dict[str, asyncio.Future] = {} self._pending_inline_callbacks: dict[str, Callable[[Any], None]] = {} self.notification_handler: Callable[[str, dict], None] | None = None + self.notification_method_handlers: dict[str, Callable[[dict], Any]] = {} self.request_handlers: dict[str, RequestHandler] = {} self._running = False self._read_thread: threading.Thread | None = None @@ -232,6 +233,21 @@ def set_notification_handler(self, handler: Callable[[str, dict], None]): """Set the handler for incoming notifications from the server.""" self.notification_handler = handler + def set_notification_method_handler( + self, method: str, handler: Callable[[dict], Any] | None + ): + """Register a handler for a specific server-to-client notification method. + + Notifications carry no ``id`` and expect no response, so they are + dispatched separately from request handlers. A registered method + handler takes precedence over the generic notification handler. The + handler may be a coroutine function; its result is awaited. + """ + if handler is None: + self.notification_method_handlers.pop(method, None) + else: + self.notification_method_handlers[method] = handler + def set_request_handler(self, method: str, handler: RequestHandler): if handler is None: self.request_handlers.pop(method, None) @@ -397,9 +413,14 @@ def _handle_message(self, message: dict): # Check if it's a notification from the server if "method" in message and "id" not in message: + method = message["method"] + params = message.get("params", {}) + handler = self.notification_method_handlers.get(method) + if handler is not None and self._loop: + # Method-specific notification handler takes precedence. + self._loop.call_soon_threadsafe(self._dispatch_notification, handler, params) + return if self.notification_handler and self._loop: - method = message["method"] - params = message.get("params", {}) # Schedule notification handler on the event loop for thread safety self._loop.call_soon_threadsafe(self.notification_handler, method, params) return @@ -427,6 +448,25 @@ def _handle_request(self, message: dict): self._loop, ) + def _dispatch_notification(self, handler: Callable[[dict], Any], params: dict): + """Invoke a method-specific notification handler. Runs on the event loop; + coroutine results are scheduled and any error is logged (notifications + carry no response, so failures never propagate to the server).""" + try: + outcome = handler(params) + except Exception: # pylint: disable=broad-except + logger.warning("Notification handler raised", exc_info=True) + return + if inspect.isawaitable(outcome): + + async def _await_outcome(): + try: + await outcome + except Exception: # pylint: disable=broad-except + logger.warning("Notification handler raised", exc_info=True) + + asyncio.ensure_future(_await_outcome()) + async def _dispatch_request(self, message: dict, handler: RequestHandler): try: params = message.get("params", {}) diff --git a/python/copilot/client.py b/python/copilot/client.py index c7d11d12b1..0560741d18 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -64,6 +64,7 @@ from .generated.rpc import ( ClientGlobalApiHandlers, ClientSessionApiHandlers, + GitHubTelemetryNotification, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, # noqa: F401 OpenCanvasInstance, @@ -389,6 +390,20 @@ class UriRuntimeConnection(RuntimeConnection): """Shared secret to authenticate the connection.""" +class _GitHubTelemetryAdapter: + """Adapts a user-provided ``on_github_telemetry`` callback to the generated + ``GitHubTelemetryHandler`` protocol. + """ + + def __init__( + self, callback: Callable[[GitHubTelemetryNotification], None] + ) -> None: + self._callback = callback + + async def event(self, params: GitHubTelemetryNotification) -> None: + self._callback(params) + + @dataclass class _CopilotClientOptions: """Internal configuration carrier used by :class:`CopilotClient`. @@ -410,6 +425,7 @@ class _CopilotClientOptions: session_idle_timeout_seconds: int | None = None enable_remote_sessions: bool = False on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None + on_github_telemetry: Callable[[GitHubTelemetryNotification], None] | None = None mode: CopilotClientMode = "copilot-cli" @@ -1099,6 +1115,7 @@ def __init__( session_idle_timeout_seconds: int | None = None, enable_remote_sessions: bool = False, on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None, + on_github_telemetry: Callable[[GitHubTelemetryNotification], None] | None = None, mode: CopilotClientMode = "copilot-cli", ): """ @@ -1143,6 +1160,10 @@ def __init__( on_list_models: Custom handler for :meth:`list_models`. When provided, the handler is called instead of querying the runtime server. + on_github_telemetry: Internal. Callback invoked when the runtime + forwards a GitHub telemetry event for a session. Registering a + handler opts every session opened by this client into telemetry + redirection. Example: >>> # Default — spawns runtime using stdio with the bundled binary @@ -1173,6 +1194,7 @@ def __init__( session_idle_timeout_seconds=session_idle_timeout_seconds, enable_remote_sessions=enable_remote_sessions, on_list_models=on_list_models, + on_github_telemetry=on_github_telemetry, mode=mode, ) connection = ( @@ -1188,6 +1210,7 @@ def __init__( self._options: _CopilotClientOptions = options self._connection: RuntimeConnection = connection self._on_list_models = options.on_list_models + self._on_github_telemetry = options.on_github_telemetry # Resolve connection-mode-specific state. self._actual_host: str = "localhost" @@ -1980,6 +2003,11 @@ async def create_session( else True ) + # Opt this connection into gitHubTelemetry.event notifications when a + # telemetry handler was registered on the client. + if self._on_github_telemetry is not None: + payload["enableGitHubTelemetryRedirection"] = True + # Add provider configuration if provided if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) @@ -2568,6 +2596,11 @@ async def resume_session( else True ) + # Opt this connection into gitHubTelemetry.event notifications when a + # telemetry handler was registered on the client. + if self._on_github_telemetry is not None: + payload["enableGitHubTelemetryRedirection"] = True + # Enable permission request callback if handler provided payload["requestPermission"] = bool(on_permission_request) @@ -3632,7 +3665,7 @@ def handle_notification(method: str, params: dict): "systemMessage.transform", self._handle_system_message_transform ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) - self._register_llm_inference_handlers() + self._register_client_global_handlers() # Start listening for messages loop = asyncio.get_running_loop() @@ -3752,7 +3785,7 @@ def handle_notification(method: str, params: dict): "systemMessage.transform", self._handle_system_message_transform ) register_client_session_api_handlers(self._client, self._get_client_session_handlers) - self._register_llm_inference_handlers() + self._register_client_global_handlers() # Start listening for messages loop = asyncio.get_running_loop() @@ -3825,15 +3858,26 @@ async def _set_session_fs_provider(self) -> None: await self._client.request("sessionFs.setProvider", params) - def _register_llm_inference_handlers(self) -> None: - if self._request_handler is None or not self._client: + def _register_client_global_handlers(self) -> None: + if not self._client: + return + llm_inference_adapter = None + if self._request_handler is not None: + llm_inference_adapter = create_copilot_request_adapter( + self._request_handler, + lambda: self._rpc.llm_inference if self._rpc is not None else None, + ) + github_telemetry_adapter = None + if self._on_github_telemetry is not None: + github_telemetry_adapter = _GitHubTelemetryAdapter(self._on_github_telemetry) + if llm_inference_adapter is None and github_telemetry_adapter is None: return - adapter = create_copilot_request_adapter( - self._request_handler, - lambda: self._rpc.llm_inference if self._rpc is not None else None, - ) register_client_global_api_handlers( - self._client, ClientGlobalApiHandlers(llm_inference=adapter) + self._client, + ClientGlobalApiHandlers( + llm_inference=llm_inference_adapter, + git_hub_telemetry=github_telemetry_adapter, + ), ) async def _set_llm_inference_provider(self) -> None: diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index b38c12ff39..f953d578db 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1778,6 +1778,77 @@ def to_dict(self) -> dict: class GhCLIAuthInfoType(Enum): GH_CLI = "gh-cli" +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryClientInfo: + """Client environment metadata describing the process that produced a telemetry event. + + Client environment metadata. + """ + cli_version: str + """Copilot CLI version string.""" + + node_version: str + """Node.js runtime version string.""" + + os_arch: str + """Operating system architecture (e.g. arm64, x64).""" + + os_platform: str + """Operating system platform (e.g. darwin, linux, win32).""" + + os_version: str + """Operating system version string.""" + + client_name: str | None = None + """Name of the client application.""" + + client_type: str | None = None + """Type of client.""" + + copilot_plan: str | None = None + """Copilot subscription plan, when known.""" + + dev_device_id: str | None = None + """Stable machine identifier for the device.""" + + is_staff: bool | None = None + """Whether the user is a GitHub/Microsoft staff member.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryClientInfo': + assert isinstance(obj, dict) + cli_version = from_str(obj.get("cli_version")) + node_version = from_str(obj.get("node_version")) + os_arch = from_str(obj.get("os_arch")) + os_platform = from_str(obj.get("os_platform")) + os_version = from_str(obj.get("os_version")) + client_name = from_union([from_str, from_none], obj.get("client_name")) + client_type = from_union([from_str, from_none], obj.get("client_type")) + copilot_plan = from_union([from_str, from_none], obj.get("copilot_plan")) + dev_device_id = from_union([from_str, from_none], obj.get("dev_device_id")) + is_staff = from_union([from_bool, from_none], obj.get("is_staff")) + return GitHubTelemetryClientInfo(cli_version, node_version, os_arch, os_platform, os_version, client_name, client_type, copilot_plan, dev_device_id, is_staff) + + def to_dict(self) -> dict: + result: dict = {} + result["cli_version"] = from_str(self.cli_version) + result["node_version"] = from_str(self.node_version) + result["os_arch"] = from_str(self.os_arch) + result["os_platform"] = from_str(self.os_platform) + result["os_version"] = from_str(self.os_version) + if self.client_name is not None: + result["client_name"] = from_union([from_str, from_none], self.client_name) + if self.client_type is not None: + result["client_type"] = from_union([from_str, from_none], self.client_type) + if self.copilot_plan is not None: + result["copilot_plan"] = from_union([from_str, from_none], self.copilot_plan) + if self.dev_device_id is not None: + result["dev_device_id"] = from_union([from_str, from_none], self.dev_device_id) + if self.is_staff is not None: + result["is_staff"] = from_union([from_bool, from_none], self.is_staff) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class HandlePendingToolCallResult: @@ -20281,6 +20352,114 @@ def to_dict(self) -> dict: result["namespacedName"] = from_union([from_str, from_none], self.namespaced_name) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryEvent: + """A single telemetry event in the runtime's native GitHub-shaped telemetry format, + forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing + GitHubTelemetryNotification distinguishes standard from restricted events; the payload + shape is identical for both. + + The telemetry event, in the runtime's native GitHub-shaped telemetry format. + """ + kind: str + """Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed).""" + + metrics: dict[str, float] + """Numeric metrics as a map from key to value.""" + + properties: dict[str, str] + """String-valued properties as a map from key to value.""" + + client: GitHubTelemetryClientInfo | None = None + """Client environment metadata.""" + + copilot_tracking_id: str | None = None + """Copilot tracking ID for user-level attribution.""" + + created_at: str | None = None + """Timestamp when the event was created (ISO 8601 format).""" + + exp_assignment_context: str | None = None + """Experiment assignment context.""" + + features: dict[str, str] | None = None + """Feature flags enabled for this session, as a map from flag to value.""" + + model_call_id: str | None = None + """Reference to the model call that produced this event.""" + + session_id: str | None = None + """Session identifier the event belongs to.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryEvent': + assert isinstance(obj, dict) + kind = from_str(obj.get("kind")) + metrics = from_dict(from_float, obj.get("metrics")) + properties = from_dict(from_str, obj.get("properties")) + client = from_union([GitHubTelemetryClientInfo.from_dict, from_none], obj.get("client")) + copilot_tracking_id = from_union([from_str, from_none], obj.get("copilot_tracking_id")) + created_at = from_union([from_str, from_none], obj.get("created_at")) + exp_assignment_context = from_union([from_str, from_none], obj.get("exp_assignment_context")) + features = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("features")) + model_call_id = from_union([from_str, from_none], obj.get("model_call_id")) + session_id = from_union([from_str, from_none], obj.get("session_id")) + return GitHubTelemetryEvent(kind, metrics, properties, client, copilot_tracking_id, created_at, exp_assignment_context, features, model_call_id, session_id) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = from_str(self.kind) + result["metrics"] = from_dict(to_float, self.metrics) + result["properties"] = from_dict(from_str, self.properties) + if self.client is not None: + result["client"] = from_union([lambda x: to_class(GitHubTelemetryClientInfo, x), from_none], self.client) + if self.copilot_tracking_id is not None: + result["copilot_tracking_id"] = from_union([from_str, from_none], self.copilot_tracking_id) + if self.created_at is not None: + result["created_at"] = from_union([from_str, from_none], self.created_at) + if self.exp_assignment_context is not None: + result["exp_assignment_context"] = from_union([from_str, from_none], self.exp_assignment_context) + if self.features is not None: + result["features"] = from_union([lambda x: from_dict(from_str, x), from_none], self.features) + if self.model_call_id is not None: + result["model_call_id"] = from_union([from_str, from_none], self.model_call_id) + if self.session_id is not None: + result["session_id"] = from_union([from_str, from_none], self.session_id) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class GitHubTelemetryNotification: + """Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the + runtime forwards to a host connection that opted into telemetry redirection for the + session. + """ + event: GitHubTelemetryEvent + """The telemetry event, in the runtime's native GitHub-shaped telemetry format.""" + + restricted: bool + """Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route + restricted events to first-party Microsoft stores only. + """ + session_id: str + """Session the telemetry event belongs to.""" + + @staticmethod + def from_dict(obj: Any) -> 'GitHubTelemetryNotification': + assert isinstance(obj, dict) + event = GitHubTelemetryEvent.from_dict(obj.get("event")) + restricted = from_bool(obj.get("restricted")) + session_id = from_str(obj.get("sessionId")) + return GitHubTelemetryNotification(event, restricted, session_id) + + def to_dict(self) -> dict: + result: dict = {} + result["event"] = to_class(GitHubTelemetryEvent, self.event) + result["restricted"] = from_bool(self.restricted) + result["sessionId"] = from_str(self.session_id) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPExecuteSamplingParams: @@ -21070,6 +21249,9 @@ class RPC: folder_trust_check_params: FolderTrustCheckParams folder_trust_check_result: FolderTrustCheckResult gh_cli_auth_info: GhCLIAuthInfo + git_hub_telemetry_client_info: GitHubTelemetryClientInfo + git_hub_telemetry_event: GitHubTelemetryEvent + git_hub_telemetry_notification: GitHubTelemetryNotification handle_pending_tool_call_request: HandlePendingToolCallRequest handle_pending_tool_call_result: HandlePendingToolCallResult history_abort_manual_compaction_result: HistoryAbortManualCompactionResult @@ -21842,6 +22024,9 @@ def from_dict(obj: Any) -> 'RPC': folder_trust_check_params = FolderTrustCheckParams.from_dict(obj.get("FolderTrustCheckParams")) folder_trust_check_result = FolderTrustCheckResult.from_dict(obj.get("FolderTrustCheckResult")) gh_cli_auth_info = GhCLIAuthInfo.from_dict(obj.get("GhCliAuthInfo")) + git_hub_telemetry_client_info = GitHubTelemetryClientInfo.from_dict(obj.get("GitHubTelemetryClientInfo")) + git_hub_telemetry_event = GitHubTelemetryEvent.from_dict(obj.get("GitHubTelemetryEvent")) + git_hub_telemetry_notification = GitHubTelemetryNotification.from_dict(obj.get("GitHubTelemetryNotification")) handle_pending_tool_call_request = HandlePendingToolCallRequest.from_dict(obj.get("HandlePendingToolCallRequest")) handle_pending_tool_call_result = HandlePendingToolCallResult.from_dict(obj.get("HandlePendingToolCallResult")) history_abort_manual_compaction_result = HistoryAbortManualCompactionResult.from_dict(obj.get("HistoryAbortManualCompactionResult")) @@ -22481,7 +22666,7 @@ def from_dict(obj: Any) -> 'RPC': subagent_settings = from_union([SubagentSettings.from_dict, from_none], obj.get("SubagentSettings")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_unregister_external_client_request, memory_configuration, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, poll_spawned_sessions_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, subagent_settings, task_progress, workspace_summary) + return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_unregister_external_client_request, memory_configuration, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, poll_spawned_sessions_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -22614,6 +22799,9 @@ def to_dict(self) -> dict: result["FolderTrustCheckParams"] = to_class(FolderTrustCheckParams, self.folder_trust_check_params) result["FolderTrustCheckResult"] = to_class(FolderTrustCheckResult, self.folder_trust_check_result) result["GhCliAuthInfo"] = to_class(GhCLIAuthInfo, self.gh_cli_auth_info) + result["GitHubTelemetryClientInfo"] = to_class(GitHubTelemetryClientInfo, self.git_hub_telemetry_client_info) + result["GitHubTelemetryEvent"] = to_class(GitHubTelemetryEvent, self.git_hub_telemetry_event) + result["GitHubTelemetryNotification"] = to_class(GitHubTelemetryNotification, self.git_hub_telemetry_notification) result["HandlePendingToolCallRequest"] = to_class(HandlePendingToolCallRequest, self.handle_pending_tool_call_request) result["HandlePendingToolCallResult"] = to_class(HandlePendingToolCallResult, self.handle_pending_tool_call_result) result["HistoryAbortManualCompactionResult"] = to_class(HistoryAbortManualCompactionResult, self.history_abort_manual_compaction_result) @@ -25451,9 +25639,16 @@ async def http_request_chunk(self, params: LlmInferenceHTTPRequestChunkRequest) "Delivers a body byte range (or a cancellation signal) for a request previously announced via httpRequestStart, correlated by requestId. The runtime fires at least one chunk per request — when there is no body, a single chunk with empty data and end=true. Mid-stream the runtime may send a chunk with cancel=true to abort the request; the SDK then stops issuing httpResponseChunk frames and may emit a terminal httpResponseChunk with error set.\n\nArgs:\n params: A request body chunk or cancellation signal.\n\nReturns:\n Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as fire-and-forget." pass +# Experimental: this API group is experimental and may change or be removed. +class GitHubTelemetryHandler(Protocol): + async def event(self, params: GitHubTelemetryNotification) -> None: + "Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session.\n\nArgs:\n params: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session." + pass + @dataclass class ClientGlobalApiHandlers: llm_inference: LlmInferenceHandler | None = None + git_hub_telemetry: GitHubTelemetryHandler | None = None def register_client_global_api_handlers( client: "JsonRpcClient", @@ -25479,6 +25674,13 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: result = await handler.http_request_chunk(request) return result.to_dict() client.set_request_handler("llmInference.httpRequestChunk", handle_llm_inference_http_request_chunk) + async def handle_git_hub_telemetry_event(params: dict) -> None: + request = GitHubTelemetryNotification.from_dict(params) + handler = handlers.git_hub_telemetry + if handler is None: return None + await handler.event(request) + return None + client.set_notification_method_handler("gitHubTelemetry.event", handle_git_hub_telemetry_event) __all__ = [ "APIKeyAuthInfo", @@ -25637,6 +25839,10 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: "FolderTrustCheckResult", "GhCLIAuthInfo", "GhCLIAuthInfoType", + "GitHubTelemetryClientInfo", + "GitHubTelemetryEvent", + "GitHubTelemetryHandler", + "GitHubTelemetryNotification", "HMACAuthInfo", "HMACAuthInfoType", "HandlePendingToolCallRequest", diff --git a/python/test_client.py b/python/test_client.py index f3f46c4d8b..4241a77aa0 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1921,3 +1921,183 @@ def on_failure(input_data, invocation): }, ) assert result == {"additionalContext": "sync-ok"} + + +class TestGitHubTelemetry: + """Unit tests for the experimental gitHubTelemetry.event consumer surface.""" + + @pytest.mark.asyncio + async def test_create_session_enables_redirection_when_handler_registered(self): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=lambda _notification: None, + ) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + assert captured["session.create"]["enableGitHubTelemetryRedirection"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_session_omits_redirection_without_handler(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + assert "enableGitHubTelemetryRedirection" not in captured["session.create"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_enables_redirection_when_handler_registered(self): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=lambda _notification: None, + ) + 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, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + assert captured["session.resume"]["enableGitHubTelemetryRedirection"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_omits_redirection_without_handler(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(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, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + assert "enableGitHubTelemetryRedirection" not in captured["session.resume"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_event_routes_to_handler_via_notification_transport(self): + import asyncio + + from copilot.generated.rpc import GitHubTelemetryNotification + + received: list = [] + done = asyncio.Event() + + def on_telemetry(notification): + received.append(notification) + done.set() + + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + on_github_telemetry=on_telemetry, + ) + await client.start() + + try: + # The method must be wired as a notification handler, NOT a request + # handler: the runtime forwards telemetry via send_notification (an + # id-less message), which never reaches the request-handler table. + assert "gitHubTelemetry.event" in client._client.notification_method_handlers + assert "gitHubTelemetry.event" not in client._client.request_handlers + + # Drive a real JSON-RPC notification (no "id") through the transport's + # message dispatch — the exact path the runtime uses. + client._client._handle_message( + { + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": "sess-telemetry", + "restricted": True, + "event": { + "kind": "tool_call_executed", + "metrics": {"duration_ms": 12.5}, + "properties": {"tool": "shell"}, + "session_id": "sess-telemetry", + }, + }, + } + ) + + await asyncio.wait_for(done.wait(), timeout=5) + + assert len(received) == 1 + notification = received[0] + assert isinstance(notification, GitHubTelemetryNotification) + assert notification.session_id == "sess-telemetry" + assert notification.restricted is True + assert notification.event.kind == "tool_call_executed" + assert notification.event.metrics["duration_ms"] == 12.5 + assert notification.event.properties["tool"] == "shell" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_event_handler_not_registered_without_option(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + assert ( + "gitHubTelemetry.event" + not in client._client.notification_method_handlers + ) + assert "gitHubTelemetry.event" not in client._client.request_handlers + finally: + await client.force_stop() diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index e0c4e21412..181f8abd57 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -3791,6 +3791,20 @@ function emitClientGlobalRegistrationMethod( const handlerField = toSnakeCase(groupName); const handlerMethod = clientSessionHandlerMethodName(method.rpcMethod); + if (method.notification) { + // Notification methods carry no response and are dispatched via the + // notification path (an `id`-less message never reaches a request + // handler), so register on the method-specific notification registry. + lines.push(` async def ${handlerVariableName}(params: dict) -> None:`); + lines.push(` request = ${paramsType}.from_dict(params)`); + lines.push(` handler = handlers.${handlerField}`); + lines.push(` if handler is None: return None`); + lines.push(` await handler.${handlerMethod}(request)`); + lines.push(` return None`); + lines.push(` client.set_notification_method_handler("${method.rpcMethod}", ${handlerVariableName})`); + return; + } + lines.push(` async def ${handlerVariableName}(params: dict) -> dict | None:`); lines.push(` request = ${paramsType}.from_dict(params)`); lines.push(` handler = handlers.${handlerField}`); From 276aa3307bff542c06a89af3f4315b5f60cd256c Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:01:52 -0700 Subject: [PATCH 5/8] go: add GitHub telemetry redirection support Regenerates the Go RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an OnGitHubTelemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 40 ++++++-- go/client_test.go | 231 ++++++++++++++++++++++++++++++++++++++++++ go/rpc/zrpc.go | 100 +++++++++++++++++- go/types.go | 7 ++ scripts/codegen/go.ts | 44 ++++++++ 5 files changed, 413 insertions(+), 9 deletions(-) diff --git a/go/client.go b/go/client.go index 970f046425..891cb11e73 100644 --- a/go/client.go +++ b/go/client.go @@ -757,6 +757,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses } else { req.IncludeSubAgentStreamingEvents = Bool(true) } + if c.options.OnGitHubTelemetry != nil { + req.EnableGitHubTelemetryRedirection = Bool(true) + } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -1023,6 +1026,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, } else { req.IncludeSubAgentStreamingEvents = Bool(true) } + if c.options.OnGitHubTelemetry != nil { + req.EnableGitHubTelemetryRedirection = Bool(true) + } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -2029,17 +2035,35 @@ func (c *Client) setupNotificationHandler() { } return session.clientSessionAPIs }) - if c.options.RequestHandler != nil { - adapter := newCopilotRequestAdapter(c.options.RequestHandler, func() *rpc.ServerLlmInferenceAPI { - if c.RPC == nil { - return nil - } - return c.RPC.LlmInference - }) - rpc.RegisterClientGlobalAPIHandlers(c.client, &rpc.ClientGlobalAPIHandlers{LlmInference: adapter}) + if c.options.RequestHandler != nil || c.options.OnGitHubTelemetry != nil { + handlers := &rpc.ClientGlobalAPIHandlers{} + if c.options.RequestHandler != nil { + handlers.LlmInference = newCopilotRequestAdapter(c.options.RequestHandler, func() *rpc.ServerLlmInferenceAPI { + if c.RPC == nil { + return nil + } + return c.RPC.LlmInference + }) + } + if c.options.OnGitHubTelemetry != nil { + handlers.GitHubTelemetry = &gitHubTelemetryAdapter{callback: c.options.OnGitHubTelemetry} + } + rpc.RegisterClientGlobalAPIHandlers(c.client, handlers) } } +// gitHubTelemetryAdapter adapts the OnGitHubTelemetry option to the generated +// rpc.GitHubTelemetryHandler interface. +type gitHubTelemetryAdapter struct { + callback func(notification *rpc.GitHubTelemetryNotification) +} + +func (a *gitHubTelemetryAdapter) Event(request *rpc.GitHubTelemetryNotification) error { + defer func() { recover() }() // Ignore handler panics + a.callback(request) + return nil +} + func (c *Client) handleSessionEvent(req sessionEventRequest) { if req.SessionID == "" { return diff --git a/go/client_test.go b/go/client_test.go index d59c71c6f9..f39f99ece6 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -3,6 +3,7 @@ package copilot import ( "context" "encoding/json" + "fmt" "net" "os" "os/exec" @@ -13,6 +14,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/github/copilot-sdk/go/internal/jsonrpc2" "github.com/github/copilot-sdk/go/internal/truncbuffer" @@ -1973,6 +1975,235 @@ func TestResumeSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { }) } +func TestCreateSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { + t.Run("forwards explicit true", func(t *testing.T) { + req := createSessionRequest{ + EnableGitHubTelemetryRedirection: 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["enableGitHubTelemetryRedirection"] != true { + t.Errorf("Expected enableGitHubTelemetryRedirection to be true, got %v", m["enableGitHubTelemetryRedirection"]) + } + }) + + t.Run("omits 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["enableGitHubTelemetryRedirection"]; ok { + t.Error("Expected enableGitHubTelemetryRedirection to be omitted when not set") + } + }) +} + +func TestResumeSessionRequest_EnableGitHubTelemetryRedirection(t *testing.T) { + t.Run("forwards explicit true", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + EnableGitHubTelemetryRedirection: 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["enableGitHubTelemetryRedirection"] != true { + t.Errorf("Expected enableGitHubTelemetryRedirection to be true, got %v", m["enableGitHubTelemetryRedirection"]) + } + }) + + t.Run("omits 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["enableGitHubTelemetryRedirection"]; ok { + t.Error("Expected enableGitHubTelemetryRedirection to be omitted when not set") + } + }) +} + +func TestClient_ForwardsGitHubTelemetryRedirectionToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{OnGitHubTelemetry: func(*rpc.GitHubTelemetryNotification) {}}, + } + + 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 + }) + + if _, err := client.CreateSession(t.Context(), &SessionConfig{}); err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertRedirectionFlagTrue(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","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.ResumeSessionWithOptions(t.Context(), "resumed", &ResumeSessionConfig{}); err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertRedirectionFlagTrue(t, <-resumeParams) +} + +func assertRedirectionFlagTrue(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) + } + if decoded["enableGitHubTelemetryRedirection"] != true { + t.Fatalf("expected enableGitHubTelemetryRedirection=true, got %v", decoded["enableGitHubTelemetryRedirection"]) + } +} + +func TestClient_OmitsGitHubTelemetryRedirectionWhenNoHandler(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{}, + } + + 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 + }) + + if _, err := client.CreateSession(t.Context(), &SessionConfig{}); err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertRedirectionFlagAbsent(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","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.ResumeSessionWithOptions(t.Context(), "resumed", &ResumeSessionConfig{}); err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertRedirectionFlagAbsent(t, <-resumeParams) +} + +func assertRedirectionFlagAbsent(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) + } + if _, ok := decoded["enableGitHubTelemetryRedirection"]; ok { + t.Fatalf("expected enableGitHubTelemetryRedirection to be omitted, got %v", decoded["enableGitHubTelemetryRedirection"]) + } +} + +func TestGitHubTelemetryNotificationRoutesToCallback(t *testing.T) { + // The runtime forwards telemetry via a JSON-RPC *notification* (no id). + // Drive a real Content-Length-framed notification through the transport and + // verify that a real Client wired with OnGitHubTelemetry routes it to the + // callback through the client's own client-global handler registration + // (setupNotificationHandler), rather than registering the adapter by hand. + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + rpcClient := jsonrpc2.NewClient(clientConn, clientConn) + rpcClient.Start() + defer rpcClient.Stop() + + // Drain the client->server direction so net.Pipe writes never block. + go func() { + buf := make([]byte, 4096) + for { + if _, err := serverConn.Read(buf); err != nil { + return + } + } + }() + + received := make(chan *rpc.GitHubTelemetryNotification, 1) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + options: ClientOptions{ + OnGitHubTelemetry: func(n *rpc.GitHubTelemetryNotification) { received <- n }, + }, + } + // setupNotificationHandler is what registers the gitHubTelemetryAdapter when + // OnGitHubTelemetry is set; exercising it here covers the real client wiring. + client.setupNotificationHandler() + + notification := map[string]any{ + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": map[string]any{ + "sessionId": "sess-telemetry", + "restricted": true, + "event": map[string]any{ + "kind": "tool_call_executed", + "metrics": map[string]any{"duration_ms": 12.5}, + "properties": map[string]any{"tool": "shell"}, + }, + }, + } + data, err := json.Marshal(notification) + if err != nil { + t.Fatalf("marshal notification: %v", err) + } + go func() { + _, _ = fmt.Fprintf(serverConn, "Content-Length: %d\r\n\r\n%s", len(data), data) + }() + + select { + case n := <-received: + if n.SessionID != "sess-telemetry" { + t.Errorf("session id = %q, want sess-telemetry", n.SessionID) + } + if !n.Restricted { + t.Error("expected restricted to be true") + } + if n.Event.Kind != "tool_call_executed" { + t.Errorf("kind = %q, want tool_call_executed", n.Event.Kind) + } + if n.Event.Metrics["duration_ms"] != 12.5 { + t.Errorf("metrics[duration_ms] = %v, want 12.5", n.Event.Metrics["duration_ms"]) + } + if n.Event.Properties["tool"] != "shell" { + t.Errorf("properties[tool] = %q, want shell", n.Event.Properties["tool"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for telemetry notification") + } +} + func TestCreateSessionRequest_EnableOnDemandInstructionDiscovery(t *testing.T) { t.Run("forwards explicit true", func(t *testing.T) { req := createSessionRequest{ diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 03ec16cea1..cfd6452a6d 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1713,6 +1713,76 @@ type FolderTrustCheckResult struct { Trusted bool `json:"trusted"` } +// Client environment metadata describing the process that produced a telemetry event. +// Experimental: GitHubTelemetryClientInfo is part of an experimental API and may change or +// be removed. +type GitHubTelemetryClientInfo struct { + // Name of the client application. + ClientName *string `json:"client_name,omitempty"` + // Type of client. + ClientType *string `json:"client_type,omitempty"` + // Copilot CLI version string. + CLIVersion string `json:"cli_version"` + // Copilot subscription plan, when known. + CopilotPlan *string `json:"copilot_plan,omitempty"` + // Stable machine identifier for the device. + DevDeviceID *string `json:"dev_device_id,omitempty"` + // Whether the user is a GitHub/Microsoft staff member. + IsStaff *bool `json:"is_staff,omitempty"` + // Node.js runtime version string. + NodeVersion string `json:"node_version"` + // Operating system architecture (e.g. arm64, x64). + OsArch string `json:"os_arch"` + // Operating system platform (e.g. darwin, linux, win32). + OsPlatform string `json:"os_platform"` + // Operating system version string. + OsVersion string `json:"os_version"` +} + +// A single telemetry event in the runtime's native GitHub-shaped telemetry format, +// forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing +// GitHubTelemetryNotification distinguishes standard from restricted events; the payload +// shape is identical for both. +// Experimental: GitHubTelemetryEvent is part of an experimental API and may change or be +// removed. +type GitHubTelemetryEvent struct { + // Client environment metadata. + Client *GitHubTelemetryClientInfo `json:"client,omitempty"` + // Copilot tracking ID for user-level attribution. + CopilotTrackingID *string `json:"copilot_tracking_id,omitempty"` + // Timestamp when the event was created (ISO 8601 format). + CreatedAt *string `json:"created_at,omitempty"` + // Experiment assignment context. + ExpAssignmentContext *string `json:"exp_assignment_context,omitempty"` + // Feature flags enabled for this session, as a map from flag to value. + Features map[string]string `json:"features,omitzero"` + // Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + Kind string `json:"kind"` + // Numeric metrics as a map from key to value. + Metrics map[string]float64 `json:"metrics"` + // Reference to the model call that produced this event. + ModelCallID *string `json:"model_call_id,omitempty"` + // String-valued properties as a map from key to value. + Properties map[string]string `json:"properties"` + // Session identifier the event belongs to. + SessionID *string `json:"session_id,omitempty"` +} + +// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the +// runtime forwards to a host connection that opted into telemetry redirection for the +// session. +// Experimental: GitHubTelemetryNotification is part of an experimental API and may change +// or be removed. +type GitHubTelemetryNotification struct { + // The telemetry event, in the runtime's native GitHub-shaped telemetry format. + Event GitHubTelemetryEvent `json:"event"` + // Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route + // restricted events to first-party Microsoft stores only. + Restricted bool `json:"restricted"` + // Session the telemetry event belongs to. + SessionID string `json:"sessionId"` +} + // Pending external tool call request ID, with the tool result or an error describing why it // failed. // Experimental: HandlePendingToolCallRequest is part of an experimental API and may change @@ -17463,6 +17533,20 @@ func RegisterClientSessionAPIHandlers(client *jsonrpc2.Client, getHandlers func( }) } +// Experimental: GitHubTelemetryHandler contains experimental APIs that may change or be +// removed. +type GitHubTelemetryHandler interface { + // Event forwards a single GitHub telemetry event to a host connection that opted into + // telemetry redirection for the session. + // + // RPC method: gitHubTelemetry.event. + // + // Parameters: Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry + // event the runtime forwards to a host connection that opted into telemetry redirection for + // the session. + Event(request *GitHubTelemetryNotification) error +} + // Experimental: LlmInferenceHandler contains experimental APIs that may change or be // removed. type LlmInferenceHandler interface { @@ -17499,7 +17583,8 @@ type LlmInferenceHandler interface { // Unlike client-session handlers these carry no implicit session id dispatch // key; a single set of handlers serves the entire connection. type ClientGlobalAPIHandlers struct { - LlmInference LlmInferenceHandler + GitHubTelemetry GitHubTelemetryHandler + LlmInference LlmInferenceHandler } func clientGlobalHandlerError(err error) *jsonrpc2.Error { @@ -17516,6 +17601,19 @@ func clientGlobalHandlerError(err error) *jsonrpc2.Error { // RegisterClientGlobalAPIHandlers registers handlers for server-to-client client-global API // calls. func RegisterClientGlobalAPIHandlers(client *jsonrpc2.Client, handlers *ClientGlobalAPIHandlers) { + client.SetRequestHandler("gitHubTelemetry.event", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request GitHubTelemetryNotification + if err := json.Unmarshal(params, &request); err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} + } + if handlers == nil || handlers.GitHubTelemetry == nil { + return nil, nil + } + if err := handlers.GitHubTelemetry.Event(&request); err != nil { + return nil, clientGlobalHandlerError(err) + } + return nil, nil + }) client.SetRequestHandler("llmInference.httpRequestChunk", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { var request LlmInferenceHTTPRequestChunkRequest if err := json.Unmarshal(params, &request); err != nil { diff --git a/go/types.go b/go/types.go index 8a7df3c46a..2e503af1ee 100644 --- a/go/types.go +++ b/go/types.go @@ -122,6 +122,11 @@ type ClientOptions struct { // this handler instead of issuing the calls itself. Works for both CAPI // and BYOK sessions. RequestHandler *CopilotRequestHandler + // OnGitHubTelemetry registers a connection-level callback (experimental) + // that receives GitHub telemetry events the runtime forwards for sessions + // opened by this client. When non-nil, every session created or resumed by + // this client opts into telemetry redirection (enableGitHubTelemetryRedirection). + OnGitHubTelemetry func(notification *rpc.GitHubTelemetryNotification) // Telemetry configures OpenTelemetry integration for the runtime. // When non-nil, COPILOT_OTEL_ENABLED=true is set and any populated // fields are mapped to the corresponding environment variables. @@ -1977,6 +1982,7 @@ type createSessionRequest struct { WorkingDirectory string `json:"workingDirectory,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` + EnableGitHubTelemetryRedirection *bool `json:"enableGitHubTelemetryRedirection,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` @@ -2072,6 +2078,7 @@ type resumeSessionRequest struct { ContinuePendingWork *bool `json:"continuePendingWork,omitempty"` Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` + EnableGitHubTelemetryRedirection *bool `json:"enableGitHubTelemetryRedirection,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 5403fb4444..fec25953d1 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -3846,6 +3846,28 @@ async function generateRpc(schemaPath?: string): Promise { } } + // Client-global methods are intentionally excluded from `allMethods` above + // (which drives server/session/clientSession wrapper synthesis). A void + // client-global result has no named definition in the schema, so its empty + // `*Result` wrapper would otherwise be referenced but never emitted. Emit it + // here, mirroring the void handling applied to the other method groups. + for (const method of collectRpcMethods(schema.clientGlobal || {})) { + const resultSchema = getMethodResultSchema(method); + const resultTypeName = goResultTypeName(method); + if ( + isVoidSchema(resultSchema) && + !method.notification && + !(resultTypeName in allDefinitions) + ) { + allDefinitions[resultTypeName] = { + title: resultTypeName, + type: "object", + properties: {}, + additionalProperties: false, + }; + } + } + const allDefinitionCollections: DefinitionCollections = { definitions: { ...(rpcDefinitions.$defs ?? {}), ...allDefinitions }, $defs: { ...allDefinitions, ...(rpcDefinitions.$defs ?? {}) }, @@ -4384,6 +4406,10 @@ function emitClientGlobalApiRegistration(lines: string[], clientSchema: Record Date: Mon, 29 Jun 2026 14:02:00 -0700 Subject: [PATCH 6/8] rust: add GitHub telemetry redirection support Regenerates the Rust RPC types for the experimental gitHubTelemetry.event clientGlobal notification and adds an on_github_telemetry callback. Registering a handler opts created/resumed sessions into telemetry redirection. The telemetry module is #[doc(hidden)] to keep it unadvertised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/generated/api_types.rs | 108 +++++++++++++++ rust/src/github_telemetry.rs | 28 ++++ rust/src/lib.rs | 73 ++++++++++ rust/src/router.rs | 35 +++++ rust/src/session.rs | 8 +- rust/src/types.rs | 2 + rust/src/wire.rs | 10 ++ rust/tests/session_test.rs | 228 ++++++++++++++++++++++++++++++++ 8 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 rust/src/github_telemetry.rs diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index b1d85c0a58..10c506c152 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -13204,6 +13204,114 @@ pub struct WorkspaceSummary { pub user_named: Option, } +/// Client environment metadata describing the process that produced a telemetry event. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryClientInfo { + /// Copilot CLI version string. + #[serde(rename = "cli_version")] + pub cli_version: String, + /// Name of the client application. + #[serde(rename = "client_name", skip_serializing_if = "Option::is_none")] + pub client_name: Option, + /// Type of client. + #[serde(rename = "client_type", skip_serializing_if = "Option::is_none")] + pub client_type: Option, + /// Copilot subscription plan, when known. + #[serde(rename = "copilot_plan", skip_serializing_if = "Option::is_none")] + pub copilot_plan: Option, + /// Stable machine identifier for the device. + #[serde(rename = "dev_device_id", skip_serializing_if = "Option::is_none")] + pub dev_device_id: Option, + /// Whether the user is a GitHub/Microsoft staff member. + #[serde(rename = "is_staff", skip_serializing_if = "Option::is_none")] + pub is_staff: Option, + /// Node.js runtime version string. + #[serde(rename = "node_version")] + pub node_version: String, + /// Operating system architecture (e.g. arm64, x64). + #[serde(rename = "os_arch")] + pub os_arch: String, + /// Operating system platform (e.g. darwin, linux, win32). + #[serde(rename = "os_platform")] + pub os_platform: String, + /// Operating system version string. + #[serde(rename = "os_version")] + pub os_version: String, +} + +/// A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryEvent { + /// Client environment metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub client: Option, + /// Copilot tracking ID for user-level attribution. + #[serde( + rename = "copilot_tracking_id", + skip_serializing_if = "Option::is_none" + )] + pub copilot_tracking_id: Option, + /// Timestamp when the event was created (ISO 8601 format). + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// Experiment assignment context. + #[serde( + rename = "exp_assignment_context", + skip_serializing_if = "Option::is_none" + )] + pub exp_assignment_context: Option, + /// Feature flags enabled for this session, as a map from flag to value. + #[serde(skip_serializing_if = "Option::is_none")] + pub features: Option>, + /// Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed). + pub kind: String, + /// Numeric metrics as a map from key to value. + pub metrics: HashMap, + /// Reference to the model call that produced this event. + #[serde(rename = "model_call_id", skip_serializing_if = "Option::is_none")] + pub model_call_id: Option, + /// String-valued properties as a map from key to value. + pub properties: HashMap, + /// Session identifier the event belongs to. + #[serde(rename = "session_id", skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +/// Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubTelemetryNotification { + /// The telemetry event, in the runtime's native GitHub-shaped telemetry format. + pub event: GitHubTelemetryEvent, + /// Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only. + pub restricted: bool, + /// Session the telemetry event belongs to. + pub session_id: SessionId, +} + /// List of Copilot models available to the resolved user, including capabilities and billing metadata. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/src/github_telemetry.rs b/rust/src/github_telemetry.rs new file mode 100644 index 0000000000..509035a224 --- /dev/null +++ b/rust/src/github_telemetry.rs @@ -0,0 +1,28 @@ +//! GitHub telemetry redirection callback surface. +//! +//! The runtime forwards per-session GitHub (hydro) telemetry to opted-in host +//! connections via the `gitHubTelemetry.event` JSON-RPC notification. The +//! payload types (`GitHubTelemetryNotification`, `GitHubTelemetryEvent`, +//! `GitHubTelemetryClientInfo`) are generated from the protocol schema and +//! re-exported here so consumers can register a callback against them via +//! [`ClientOptions::on_github_telemetry`](crate::ClientOptions::on_github_telemetry). +//! +//! Experimental: this surface is part of the GitHub telemetry redirection +//! feature and may change or be removed without notice. + +use std::sync::Arc; + +#[doc(hidden)] +pub use crate::generated::api_types::{ + GitHubTelemetryClientInfo, GitHubTelemetryEvent, GitHubTelemetryNotification, +}; + +/// Callback invoked for each `gitHubTelemetry.event` notification forwarded by +/// the runtime to a connection that opted into telemetry redirection. +/// +/// Set via +/// [`ClientOptions::on_github_telemetry`](crate::ClientOptions::on_github_telemetry). +/// Registering a callback auto-enables telemetry redirection on every session +/// created or resumed by the client. +#[doc(hidden)] +pub type GitHubTelemetryCallback = Arc; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 22fdc53d78..136def8570 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -15,6 +15,10 @@ pub use errors::*; /// model-layer HTTP and WebSocket traffic the runtime issues for both CAPI and /// BYOK sessions. pub mod copilot_request_handler; +/// GitHub telemetry redirection callback surface (experimental). Public but +/// `#[doc(hidden)]` — re-exports the generated telemetry payload types. +#[doc(hidden)] +pub mod github_telemetry; /// Event handler traits for session lifecycle. pub mod handler; /// Lifecycle hook callbacks (pre/post tool use, prompt submission, session start/end). @@ -257,6 +261,15 @@ pub struct ClientOptions { /// [`CopilotRequestHandler`] /// instead of issuing the calls itself. pub request_handler: Option>, + /// Connection-level GitHub telemetry redirection callback (experimental). + /// + /// When set, every session created or resumed on this client opts into + /// telemetry redirection (`enableGitHubTelemetryRedirection`) and the + /// callback is invoked for each `gitHubTelemetry.event` notification the + /// runtime forwards. `#[doc(hidden)]`, consistent with the experimental + /// telemetry payload types. + #[doc(hidden)] + pub on_github_telemetry: Option, /// Optional [`TraceContextProvider`] used to inject W3C Trace Context /// headers (`traceparent` / `tracestate`) on outbound `session.create`, /// `session.resume`, and `session.send` requests. @@ -336,6 +349,10 @@ impl std::fmt::Debug for ClientOptions { "request_handler", &self.request_handler.as_ref().map(|_| ""), ) + .field( + "on_github_telemetry", + &self.on_github_telemetry.as_ref().map(|_| ""), + ) .field( "on_get_trace_context", &self.on_get_trace_context.as_ref().map(|_| ""), @@ -584,6 +601,7 @@ impl Default for ClientOptions { on_list_models: None, session_fs: None, request_handler: None, + on_github_telemetry: None, on_get_trace_context: None, telemetry: None, base_directory: None, @@ -728,6 +746,20 @@ impl ClientOptions { self } + /// Register a connection-level GitHub telemetry redirection callback + /// (internal/experimental). Registering a callback auto-enables telemetry + /// redirection on every session created or resumed on this client; the + /// callback fires for each forwarded `gitHubTelemetry.event` notification. + /// The callback is wrapped in `Arc` internally. + #[doc(hidden)] + pub fn with_on_github_telemetry(mut self, callback: F) -> Self + where + F: Fn(crate::github_telemetry::GitHubTelemetryNotification) + Send + Sync + 'static, + { + self.on_github_telemetry = Some(Arc::new(callback)); + self + } + /// Set the [`TraceContextProvider`] used to inject W3C Trace Context /// headers on outbound `session.create` / `session.resume` / /// `session.send` requests. The provider is wrapped in `Arc` internally. @@ -853,6 +885,11 @@ struct ClientInner { /// Inbound `llmInference.*` dispatcher, installed when /// [`ClientOptions::request_handler`] is set. llm_inference: OnceLock>, + /// Connection-level GitHub telemetry redirection callback, set from + /// [`ClientOptions::on_github_telemetry`]. Drives the + /// `enableGitHubTelemetryRedirection` wire flag and the + /// `gitHubTelemetry.event` notification dispatch. + on_github_telemetry: Option, on_get_trace_context: Option>, /// Token sent in the `connect` handshake. Auto-generated when the /// SDK spawns its own CLI in TCP mode and no explicit token is set; @@ -1005,6 +1042,7 @@ impl Client { session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, + options.on_github_telemetry, effective_connection_token.clone(), options.mode, )? @@ -1032,6 +1070,7 @@ impl Client { session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, + options.on_github_telemetry, effective_connection_token.clone(), options.mode, )? @@ -1050,6 +1089,7 @@ impl Client { session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, + options.on_github_telemetry, effective_connection_token.clone(), options.mode, )? @@ -1097,6 +1137,7 @@ impl Client { &client.inner.notification_tx, &client.inner.request_rx, Some(dispatcher.clone()), + client.inner.on_github_telemetry.clone(), ); client.rpc().llm_inference().set_provider().await?; debug!( @@ -1129,6 +1170,7 @@ impl Client { false, None, None, + None, ClientMode::default(), ) } @@ -1157,6 +1199,7 @@ impl Client { false, Some(provider), None, + None, ClientMode::default(), ) } @@ -1180,11 +1223,37 @@ impl Client { false, false, None, + None, token, ClientMode::default(), ) } + /// Construct a [`Client`] from raw streams with a preset GitHub telemetry + /// callback, for integration testing telemetry redirection. + #[doc(hidden)] + #[cfg(any(test, feature = "test-support"))] + pub fn from_streams_with_github_telemetry( + reader: impl AsyncRead + Unpin + Send + 'static, + writer: impl AsyncWrite + Unpin + Send + 'static, + cwd: PathBuf, + on_github_telemetry: crate::github_telemetry::GitHubTelemetryCallback, + ) -> Result { + Self::from_transport( + reader, + writer, + None, + cwd, + None, + false, + false, + None, + Some(on_github_telemetry), + None, + ClientMode::default(), + ) + } + /// Public test-only wrapper around the random connection-token /// generator used by [`Client::start`] when the SDK spawns a TCP /// server without an explicit token. Lets integration tests @@ -1205,6 +1274,7 @@ impl Client { session_fs_configured: bool, session_fs_sqlite_declared: bool, on_get_trace_context: Option>, + on_github_telemetry: Option, effective_connection_token: Option, mode: ClientMode, ) -> Result { @@ -1237,6 +1307,7 @@ impl Client { session_fs_configured, session_fs_sqlite_declared, llm_inference: OnceLock::new(), + on_github_telemetry, on_get_trace_context, effective_connection_token, mode, @@ -1646,6 +1717,7 @@ impl Client { &self.inner.notification_tx, &self.inner.request_rx, self.inner.llm_inference.get().cloned(), + self.inner.on_github_telemetry.clone(), ); self.inner.router.register(session_id) } @@ -2732,6 +2804,7 @@ mod tests { session_fs_configured: false, session_fs_sqlite_declared: false, llm_inference: OnceLock::new(), + on_github_telemetry: None, on_get_trace_context: None, effective_connection_token: None, mode: ClientMode::default(), diff --git a/rust/src/router.rs b/rust/src/router.rs index cc621c287c..cbd900d2d6 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -86,6 +86,7 @@ impl SessionRouter { notification_tx: &broadcast::Sender, request_rx: &Mutex>>, llm_inference: Option>, + github_telemetry: Option, ) { let mut started = self.started.lock(); if *started { @@ -100,6 +101,40 @@ impl SessionRouter { loop { match notif_rx.recv().await { Ok(notification) => { + // Client-global `gitHubTelemetry.event` notifications carry + // no routable session and are surfaced to the consumer + // callback (if any) registered at client construction. + if notification.method == "gitHubTelemetry.event" { + if let Some(ref callback) = github_telemetry { + let Some(ref params) = notification.params else { + continue; + }; + match serde_json::from_value::< + crate::github_telemetry::GitHubTelemetryNotification, + >(params.clone()) + { + Ok(telemetry) => { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || callback(telemetry), + )) + .is_err() + { + warn!( + "gitHubTelemetry.event callback panicked; \ + continuing notification routing" + ); + } + } + Err(e) => { + warn!( + error = %e, + "failed to deserialize gitHubTelemetry.event notification" + ); + } + } + } + continue; + } if notification.method != "session.event" { continue; } diff --git a/rust/src/session.rs b/rust/src/session.rs index 18b91b4377..08b7215c6e 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -872,7 +872,9 @@ impl Client { let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; - let (wire, mut runtime) = config.into_wire(local_session_id.clone())?; + let (mut wire, mut runtime) = config.into_wire(local_session_id.clone())?; + wire.enable_github_telemetry_redirection = + self.inner.on_github_telemetry.is_some().then_some(true); let permission_handler = crate::permission::resolve_handler( runtime.permission_handler.take(), @@ -1130,7 +1132,9 @@ impl Client { let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; - let (wire, mut runtime) = config.into_wire()?; + let (mut wire, mut runtime) = config.into_wire()?; + wire.enable_github_telemetry_redirection = + self.inner.on_github_telemetry.is_some().then_some(true); let permission_handler = crate::permission::resolve_handler( runtime.permission_handler.take(), diff --git a/rust/src/types.rs b/rust/src/types.rs index 75408db026..9994079ba3 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2135,6 +2135,7 @@ impl SessionConfig { remote_session: self.remote_session, cloud: self.cloud, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, + enable_github_telemetry_redirection: None, commands: wire_commands, exp_assignments: self.exp_assignments, }; @@ -3093,6 +3094,7 @@ impl ResumeSessionConfig { github_token: self.github_token, remote_session: self.remote_session, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, + enable_github_telemetry_redirection: None, commands: wire_commands, exp_assignments: self.exp_assignments, suppress_resume_event: self.suppress_resume_event, diff --git a/rust/src/wire.rs b/rust/src/wire.rs index e6dad66d58..ba5141f682 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -153,6 +153,11 @@ pub(crate) struct SessionCreateWire { pub cloud: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include_sub_agent_streaming_events: Option, + #[serde( + rename = "enableGitHubTelemetryRedirection", + skip_serializing_if = "Option::is_none" + )] + pub enable_github_telemetry_redirection: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -268,6 +273,11 @@ pub(crate) struct SessionResumeWire { pub remote_session: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include_sub_agent_streaming_events: Option, + #[serde( + rename = "enableGitHubTelemetryRedirection", + skip_serializing_if = "Option::is_none" + )] + pub enable_github_telemetry_redirection: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, /// Maps to wire field `disableResume`. diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 98c6248230..ff8f7f3223 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -373,6 +373,234 @@ async fn create_session_sends_canvas_wire_fields() { timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); } +fn make_client_with_telemetry( + callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback, +) -> (Client, tokio::io::DuplexStream, tokio::io::DuplexStream) { + let (client_write, server_read) = duplex(8192); + let (server_write, client_read) = duplex(8192); + let client = Client::from_streams_with_github_telemetry( + client_read, + client_write, + std::env::temp_dir(), + callback, + ) + .unwrap(); + (client, server_read, server_write) +} + +#[tokio::test] +async fn create_and_resume_send_github_telemetry_redirection_when_callback_registered() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback = + Arc::new(|_notification| {}); + let (client, mut server_read, mut server_write) = make_client_with_telemetry(callback); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session(SessionConfig::default()) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["enableGitHubTelemetryRedirection"], true); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id.clone() }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client + .resume_session(ResumeSessionConfig::new(SessionId::from(session_id))) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["enableGitHubTelemetryRedirection"], true); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn create_session_omits_github_telemetry_redirection_without_callback() { + let (client, mut server_read, mut server_write) = make_client(); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session(SessionConfig::default()) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert!( + request["params"] + .get("enableGitHubTelemetryRedirection") + .is_none_or(Value::is_null), + "redirection flag should be omitted when no callback is registered" + ); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn resume_session_omits_github_telemetry_redirection_without_callback() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .resume_session(ResumeSessionConfig::new(SessionId::from( + "sess-1".to_string(), + ))) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert!( + request["params"] + .get("enableGitHubTelemetryRedirection") + .is_none_or(Value::is_null), + "redirection flag should be omitted when no callback is registered" + ); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": "sess-1" }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn github_telemetry_event_dispatches_to_callback() { + use github_copilot_sdk::github_telemetry::GitHubTelemetryNotification; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback = + Arc::new(move |notification| { + let _ = tx.send(notification); + }); + let (client, mut server_read, mut server_write) = make_client_with_telemetry(callback); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session(SessionConfig::default()) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id.clone() }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": session_id.clone(), + "restricted": false, + "event": { + "kind": "tool_call_executed", + "properties": { "tool": "bash" }, + "metrics": { "duration_ms": 12.0 }, + "session_id": session_id.clone(), + "created_at": "2025-01-01T00:00:00Z" + } + } + }); + write_framed( + &mut server_write, + &serde_json::to_vec(¬ification).unwrap(), + ) + .await; + + let received = timeout(TIMEOUT, rx.recv()).await.unwrap().unwrap(); + assert_eq!(received.session_id, session_id); + assert!(!received.restricted); + assert_eq!(received.event.kind, "tool_call_executed"); + assert_eq!( + received.event.properties.get("tool").map(String::as_str), + Some("bash") + ); + assert_eq!( + received.event.metrics.get("duration_ms").copied(), + Some(12.0) + ); + assert_eq!( + received.event.created_at.as_deref(), + Some("2025-01-01T00:00:00Z") + ); +} + #[tokio::test] async fn provider_canvas_dispatch_routes_direct_canvas_action_requests() { let (session, mut server) = create_session_pair_with_config(|cfg| { From 50012683a937c909caa4c37f0e8c8c4deda1302f Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 14:02:09 -0700 Subject: [PATCH 7/8] java: add GitHub telemetry redirection support Adds the experimental gitHubTelemetry.event notification adapter and an onGitHubTelemetry client option. Registering a handler opts created/resumed sessions into telemetry redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/github/copilot/CopilotClient.java | 22 ++ .../copilot/GitHubTelemetryAdapter.java | 46 +++ .../copilot/rpc/CopilotClientOptions.java | 39 +++ .../copilot/rpc/CreateSessionRequest.java | 24 ++ .../rpc/GitHubTelemetryClientInfo.java | 143 +++++++++ .../copilot/rpc/GitHubTelemetryEvent.java | 147 +++++++++ .../rpc/GitHubTelemetryNotification.java | 62 ++++ .../copilot/rpc/ResumeSessionRequest.java | 24 ++ .../github/copilot/GitHubTelemetryTest.java | 283 ++++++++++++++++++ 9 files changed, 790 insertions(+) create mode 100644 java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java create mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java create mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java create mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java create mode 100644 java/src/test/java/com/github/copilot/GitHubTelemetryTest.java diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 1a49941895..1b880a87c7 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -257,6 +257,14 @@ private Connection startCoreBody() { llmAdapter.registerHandlers(rpc); } + // Register the GitHub telemetry redirection handler when configured. + java.util.function.Consumer onGitHubTelemetry = this.options + .getOnGitHubTelemetry(); + if (onGitHubTelemetry != null) { + GitHubTelemetryAdapter telemetryAdapter = new GitHubTelemetryAdapter(onGitHubTelemetry); + telemetryAdapter.registerHandlers(rpc); + } + // Verify protocol version verifyProtocolVersion(connection); LoggingHelpers.logTiming(LOG, Level.FINE, @@ -578,6 +586,13 @@ public CompletableFuture createSession(SessionConfig config) { request.setSystemMessage(extracted.wireSystemMessage()); } + // Opt this session into GitHub telemetry redirection when a + // connection-level handler is registered (mirrors the runtime's + // hand-written capability flag, not part of the codegen'd contract). + if (options.getOnGitHubTelemetry() != null) { + request.setEnableGitHubTelemetryRedirection(true); + } + // Empty mode: validate availableTools and set toolFilterPrecedence if (options.getMode() == CopilotClientMode.EMPTY) { if (config.getAvailableTools() == null) { @@ -720,6 +735,13 @@ public CompletableFuture resumeSession(String sessionId, ResumeS request.setSystemMessage(extracted.wireSystemMessage()); } + // Opt this session into GitHub telemetry redirection when a + // connection-level handler is registered (mirrors the runtime's + // hand-written capability flag, not part of the codegen'd contract). + if (options.getOnGitHubTelemetry() != null) { + request.setEnableGitHubTelemetryRedirection(true); + } + // Empty mode: validate availableTools and set toolFilterPrecedence for resume // path if (options.getMode() == CopilotClientMode.EMPTY) { diff --git a/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java new file mode 100644 index 0000000000..3589eb15da --- /dev/null +++ b/java/src/main/java/com/github/copilot/GitHubTelemetryAdapter.java @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.rpc.GitHubTelemetryNotification; + +/** + * Bridges the runtime's {@code gitHubTelemetry.event} client-global + * notification to a consumer's {@code onGitHubTelemetry} callback. The + * notification carries per-session GitHub (hydro) telemetry the runtime + * forwards to connections that opted into telemetry redirection. + */ +final class GitHubTelemetryAdapter { + + private static final Logger LOG = Logger.getLogger(GitHubTelemetryAdapter.class.getName()); + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + + private final Consumer callback; + + GitHubTelemetryAdapter(Consumer callback) { + this.callback = callback; + } + + void registerHandlers(JsonRpcClient rpc) { + rpc.registerMethodHandler("gitHubTelemetry.event", (rpcId, params) -> handleEvent(params)); + } + + private void handleEvent(JsonNode params) { + try { + GitHubTelemetryNotification notification = MAPPER.treeToValue(params, GitHubTelemetryNotification.class); + if (notification != null) { + callback.accept(notification); + } + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling gitHubTelemetry.event notification", e); + } + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index e9f59aa646..2c2897e733 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -11,10 +11,12 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.function.Consumer; import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.github.copilot.CopilotExperimental; import com.github.copilot.CopilotRequestHandler; import java.util.Optional; import java.util.OptionalInt; @@ -57,6 +59,7 @@ public class CopilotClientOptions { private CopilotClientMode mode = CopilotClientMode.COPILOT_CLI; private Supplier>> onListModels; private CopilotRequestHandler requestHandler; + private Consumer onGitHubTelemetry; private int port; private TelemetryConfig telemetry; private Integer sessionIdleTimeoutSeconds; @@ -484,6 +487,41 @@ public CopilotClientOptions setRequestHandler(CopilotRequestHandler requestHandl return this; } + /** + * Gets the connection-level GitHub telemetry redirection handler. + * + *

+ * Experimental: this option may change or be removed without notice. + * + * @return the telemetry handler, or {@code null} if not set + */ + @JsonIgnore + @CopilotExperimental + public Consumer getOnGitHubTelemetry() { + return onGitHubTelemetry; + } + + /** + * Sets a connection-level handler for GitHub telemetry redirection + * (experimental). + * + *

+ * When provided, the client opts every session it creates or resumes into + * telemetry redirection, and the runtime forwards each per-session telemetry + * event to this handler via the {@code gitHubTelemetry.event} notification. + * + * @param onGitHubTelemetry + * the telemetry handler (must not be {@code null}) + * @return this options instance for method chaining + * @throws IllegalArgumentException + * if {@code onGitHubTelemetry} is {@code null} + */ + @CopilotExperimental + public CopilotClientOptions setOnGitHubTelemetry(Consumer onGitHubTelemetry) { + this.onGitHubTelemetry = Objects.requireNonNull(onGitHubTelemetry, "onGitHubTelemetry must not be null"); + return this; + } + /** * Gets the TCP port for the CLI server. * @@ -720,6 +758,7 @@ public CopilotClientOptions clone() { copy.logLevel = this.logLevel; copy.onListModels = this.onListModels; copy.requestHandler = this.requestHandler; + copy.onGitHubTelemetry = this.onGitHubTelemetry; copy.port = this.port; copy.remote = this.remote; copy.sessionIdleTimeoutSeconds = this.sessionIdleTimeoutSeconds; 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 8fc966c6f1..f498c06c69 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -92,6 +92,9 @@ public final class CreateSessionRequest { @JsonProperty("includeSubAgentStreamingEvents") private Boolean includeSubAgentStreamingEvents; + @JsonProperty("enableGitHubTelemetryRedirection") + private Boolean enableGitHubTelemetryRedirection; + @JsonProperty("mcpServers") private Map mcpServers; @@ -778,6 +781,27 @@ public void clearIncludeSubAgentStreamingEvents() { this.includeSubAgentStreamingEvents = null; } + /** Gets the GitHub telemetry redirection flag. @return the flag */ + public Boolean getEnableGitHubTelemetryRedirection() { + return enableGitHubTelemetryRedirection; + } + + /** + * Sets the GitHub telemetry redirection flag. @param + * enableGitHubTelemetryRedirection the flag + */ + public void setEnableGitHubTelemetryRedirection(boolean enableGitHubTelemetryRedirection) { + this.enableGitHubTelemetryRedirection = enableGitHubTelemetryRedirection; + } + + /** + * Clears the enableGitHubTelemetryRedirection setting, reverting to the default + * behavior. + */ + public void clearEnableGitHubTelemetryRedirection() { + this.enableGitHubTelemetryRedirection = null; + } + /** Gets the commands wire definitions. @return the commands */ public List getCommands() { return commands == null ? null : Collections.unmodifiableList(commands); diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java new file mode 100644 index 0000000000..abf1600d2c --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryClientInfo.java @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * Client environment metadata describing the process that produced a telemetry + * event. + * + *

+ * Internal/experimental: this type is part of the GitHub telemetry redirection + * surface and may change or be removed without notice. + * + * @since 1.0.0 + */ +@CopilotExperimental +public class GitHubTelemetryClientInfo { + + @JsonProperty("cli_version") + private String cliVersion = ""; + + @JsonProperty("client_name") + private String clientName; + + @JsonProperty("client_type") + private String clientType; + + @JsonProperty("copilot_plan") + private String copilotPlan; + + @JsonProperty("dev_device_id") + private String devDeviceId; + + @JsonProperty("is_staff") + private Boolean isStaff; + + @JsonProperty("node_version") + private String nodeVersion = ""; + + @JsonProperty("os_arch") + private String osArch = ""; + + @JsonProperty("os_platform") + private String osPlatform = ""; + + @JsonProperty("os_version") + private String osVersion = ""; + + /** + * Gets the Copilot CLI version string. + * + * @return the CLI version + */ + public String getCliVersion() { + return cliVersion; + } + + /** + * Gets the name of the client application. + * + * @return the client name, or {@code null} if unknown + */ + public String getClientName() { + return clientName; + } + + /** + * Gets the type of client. + * + * @return the client type, or {@code null} if unknown + */ + public String getClientType() { + return clientType; + } + + /** + * Gets the Copilot subscription plan, when known. + * + * @return the Copilot plan, or {@code null} if unknown + */ + public String getCopilotPlan() { + return copilotPlan; + } + + /** + * Gets the stable machine identifier for the device. + * + * @return the device identifier, or {@code null} if unknown + */ + public String getDevDeviceId() { + return devDeviceId; + } + + /** + * Gets whether the user is a GitHub/Microsoft staff member. + * + * @return the staff flag, or {@code null} if unknown + */ + public Boolean getIsStaff() { + return isStaff; + } + + /** + * Gets the Node.js runtime version string. + * + * @return the Node.js version + */ + public String getNodeVersion() { + return nodeVersion; + } + + /** + * Gets the operating system architecture (e.g. arm64, x64). + * + * @return the OS architecture + */ + public String getOsArch() { + return osArch; + } + + /** + * Gets the operating system platform (e.g. darwin, linux, win32). + * + * @return the OS platform + */ + public String getOsPlatform() { + return osPlatform; + } + + /** + * Gets the operating system version string. + * + * @return the OS version + */ + public String getOsVersion() { + return osVersion; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java new file mode 100644 index 0000000000..f4e353e373 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryEvent.java @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.Collections; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * A single telemetry event in the runtime's native GitHub-shaped telemetry + * format, forwarded verbatim to opted-in hosts. + * + *

+ * Internal/experimental: this type is part of the GitHub telemetry redirection + * surface and may change or be removed without notice. + * + * @since 1.0.0 + */ +@CopilotExperimental +public class GitHubTelemetryEvent { + + @JsonProperty("client") + private GitHubTelemetryClientInfo client; + + @JsonProperty("copilot_tracking_id") + private String copilotTrackingId; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("exp_assignment_context") + private String expAssignmentContext; + + @JsonProperty("features") + private Map features; + + @JsonProperty("kind") + private String kind = ""; + + @JsonProperty("metrics") + private Map metrics = Collections.emptyMap(); + + @JsonProperty("model_call_id") + private String modelCallId; + + @JsonProperty("properties") + private Map properties = Collections.emptyMap(); + + @JsonProperty("session_id") + private String sessionId; + + /** + * Gets the client environment metadata. + * + * @return the client info, or {@code null} if absent + */ + public GitHubTelemetryClientInfo getClient() { + return client; + } + + /** + * Gets the Copilot tracking ID for user-level attribution. + * + * @return the tracking ID, or {@code null} if absent + */ + public String getCopilotTrackingId() { + return copilotTrackingId; + } + + /** + * Gets the timestamp when the event was created (ISO 8601 format). + * + * @return the creation timestamp, or {@code null} if absent + */ + public String getCreatedAt() { + return createdAt; + } + + /** + * Gets the experiment assignment context. + * + * @return the assignment context, or {@code null} if absent + */ + public String getExpAssignmentContext() { + return expAssignmentContext; + } + + /** + * Gets the feature flags enabled for this session, as a map from flag to value. + * + * @return the features map, or {@code null} if absent + */ + public Map getFeatures() { + return features; + } + + /** + * Gets the event type/kind (e.g. get_completion_with_tools_turn, + * tool_call_executed). + * + * @return the event kind + */ + public String getKind() { + return kind; + } + + /** + * Gets the numeric metrics as a map from key to value. + * + * @return the metrics map + */ + public Map getMetrics() { + return metrics; + } + + /** + * Gets the reference to the model call that produced this event. + * + * @return the model call ID, or {@code null} if absent + */ + public String getModelCallId() { + return modelCallId; + } + + /** + * Gets the string-valued properties as a map from key to value. + * + * @return the properties map + */ + public Map getProperties() { + return properties; + } + + /** + * Gets the session identifier the event belongs to. + * + * @return the session ID, or {@code null} if absent + */ + public String getSessionId() { + return sessionId; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java new file mode 100644 index 0000000000..637c84b4f6 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubTelemetryNotification.java @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.github.copilot.CopilotExperimental; + +/** + * Payload for a {@code gitHubTelemetry.event} notification: a single GitHub + * telemetry event the runtime forwards to a host connection that opted into + * telemetry redirection for the session. + * + *

+ * Internal/experimental: this type is part of the GitHub telemetry redirection + * surface and may change or be removed without notice. + * + * @since 1.0.0 + */ +@CopilotExperimental +public class GitHubTelemetryNotification { + + @JsonProperty("event") + private GitHubTelemetryEvent event = new GitHubTelemetryEvent(); + + @JsonProperty("restricted") + private boolean restricted; + + @JsonProperty("sessionId") + private String sessionId = ""; + + /** + * Gets the telemetry event, in the runtime's native GitHub-shaped telemetry + * format. + * + * @return the telemetry event + */ + public GitHubTelemetryEvent getEvent() { + return event; + } + + /** + * Gets whether this is a restricted telemetry event (cli.restricted_telemetry). + * Hosts must route restricted events to first-party Microsoft stores only. + * + * @return {@code true} if the event is restricted + */ + public boolean isRestricted() { + return restricted; + } + + /** + * Gets the session the telemetry event belongs to. + * + * @return the session ID + */ + public String getSessionId() { + return sessionId; + } +} 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 2b25875d7f..3f54cd7da3 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -135,6 +135,9 @@ public final class ResumeSessionRequest { @JsonProperty("includeSubAgentStreamingEvents") private Boolean includeSubAgentStreamingEvents; + @JsonProperty("enableGitHubTelemetryRedirection") + private Boolean enableGitHubTelemetryRedirection; + @JsonProperty("mcpServers") private Map mcpServers; @@ -663,6 +666,27 @@ public void clearIncludeSubAgentStreamingEvents() { this.includeSubAgentStreamingEvents = null; } + /** Gets the GitHub telemetry redirection flag. @return the flag */ + public Boolean getEnableGitHubTelemetryRedirection() { + return enableGitHubTelemetryRedirection; + } + + /** + * Sets the GitHub telemetry redirection flag. @param + * enableGitHubTelemetryRedirection the flag + */ + public void setEnableGitHubTelemetryRedirection(boolean enableGitHubTelemetryRedirection) { + this.enableGitHubTelemetryRedirection = enableGitHubTelemetryRedirection; + } + + /** + * Clears the enableGitHubTelemetryRedirection setting, reverting to the default + * behavior. + */ + public void clearEnableGitHubTelemetryRedirection() { + this.enableGitHubTelemetryRedirection = null; + } + /** Gets MCP servers. @return the servers map */ public Map getMcpServers() { return mcpServers == null ? null : Collections.unmodifiableMap(mcpServers); diff --git a/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java new file mode 100644 index 0000000000..d894ba90ce --- /dev/null +++ b/java/src/test/java/com/github/copilot/GitHubTelemetryTest.java @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.GitHubTelemetryNotification; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.SessionConfig; + +/** + * Exercises the hand-written GitHub telemetry redirection surface: the + * {@code gitHubTelemetry.event} notification adapter, the + * {@code enableGitHubTelemetryRedirection} capability flag on the create/resume + * requests, and the {@code onGitHubTelemetry} client option. + */ +@AllowCopilotExperimental +class GitHubTelemetryTest { + + private record SocketPair(JsonRpcClient client, Socket serverSide, + ServerSocket serverSocket) implements AutoCloseable { + + @Override + public void close() throws Exception { + client.close(); + serverSide.close(); + serverSocket.close(); + } + } + + private SocketPair createSocketPair() throws Exception { + var serverSocket = new ServerSocket(0); + var clientSocket = new Socket("localhost", serverSocket.getLocalPort()); + var serverSide = serverSocket.accept(); + var client = JsonRpcClient.fromSocket(clientSocket); + return new SocketPair(client, serverSide, serverSocket); + } + + private void writeRpcMessage(OutputStream out, String json) throws IOException { + byte[] content = json.getBytes(StandardCharsets.UTF_8); + String header = "Content-Length: " + content.length + "\r\n\r\n"; + out.write(header.getBytes(StandardCharsets.UTF_8)); + out.write(content); + out.flush(); + } + + @Test + void adapterDispatchesNotificationToHandlerWithTypedPayload() throws Exception { + try (var pair = createSocketPair()) { + var received = new CompletableFuture(); + Consumer handler = received::complete; + new GitHubTelemetryAdapter(handler).registerHandlers(pair.client()); + + String notification = """ + { + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": "sess-123", + "restricted": true, + "event": { + "kind": "tool_call_executed", + "created_at": "2024-01-01T00:00:00Z", + "model_call_id": "call-9", + "properties": { "tool": "shell" }, + "metrics": { "duration_ms": 42.5 }, + "exp_assignment_context": "ctx", + "features": { "flag_a": "on" }, + "session_id": "sess-123", + "copilot_tracking_id": "track-1", + "client": { + "cli_version": "1.2.3", + "os_platform": "win32", + "os_version": "10", + "os_arch": "x64", + "node_version": "20.0.0", + "is_staff": false + } + } + } + } + """; + writeRpcMessage(pair.serverSide().getOutputStream(), notification); + + GitHubTelemetryNotification result = received.get(5, TimeUnit.SECONDS); + assertEquals("sess-123", result.getSessionId()); + assertTrue(result.isRestricted()); + + var event = result.getEvent(); + assertNotNull(event); + assertEquals("tool_call_executed", event.getKind()); + assertEquals("2024-01-01T00:00:00Z", event.getCreatedAt()); + assertEquals("call-9", event.getModelCallId()); + assertEquals("shell", event.getProperties().get("tool")); + assertEquals(42.5, event.getMetrics().get("duration_ms")); + assertEquals("ctx", event.getExpAssignmentContext()); + assertEquals("on", event.getFeatures().get("flag_a")); + assertEquals("sess-123", event.getSessionId()); + assertEquals("track-1", event.getCopilotTrackingId()); + + var client = event.getClient(); + assertNotNull(client); + assertEquals("1.2.3", client.getCliVersion()); + assertEquals("win32", client.getOsPlatform()); + assertEquals("x64", client.getOsArch()); + assertEquals("20.0.0", client.getNodeVersion()); + assertEquals(Boolean.FALSE, client.getIsStaff()); + } + } + + @Test + void clientOptsSessionsIntoRedirectionAndReceivesEvents() throws Exception { + var received = new CompletableFuture(); + Consumer handler = received::complete; + + try (var server = new FakeRuntimeServer(); + var client = new CopilotClient( + new CopilotClientOptions().setCliUrl(server.url()).setOnGitHubTelemetry(handler))) { + + client.start().get(15, TimeUnit.SECONDS); + + // Creating a session must opt it into telemetry redirection. + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, + TimeUnit.SECONDS); + JsonNode createParams = server.awaitCreate(); + assertTrue(createParams.path("enableGitHubTelemetryRedirection").asBoolean(), + "create request should carry enableGitHubTelemetryRedirection=true"); + + // The adapter registered on connect should forward server-pushed events. + server.sendTelemetry(Map.of("sessionId", "sess-xyz", "restricted", false, "event", + Map.of("kind", "session_started", "session_id", "sess-xyz"))); + GitHubTelemetryNotification event = received.get(5, TimeUnit.SECONDS); + assertEquals("sess-xyz", event.getSessionId()); + assertFalse(event.isRestricted()); + assertEquals("session_started", event.getEvent().getKind()); + + // Resuming a session must opt it in as well. + client.resumeSession("resume-1", + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(15, TimeUnit.SECONDS); + JsonNode resumeParams = server.awaitResume(); + assertTrue(resumeParams.path("enableGitHubTelemetryRedirection").asBoolean(), + "resume request should carry enableGitHubTelemetryRedirection=true"); + } + } + + @Test + void clientOmitsRedirectionWhenNoHandler() throws Exception { + try (var server = new FakeRuntimeServer(); + var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { + + client.start().get(15, TimeUnit.SECONDS); + + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(15, + TimeUnit.SECONDS); + JsonNode createParams = server.awaitCreate(); + assertFalse(createParams.has("enableGitHubTelemetryRedirection"), + "create request should omit the flag when no handler is registered"); + + client.resumeSession("resume-1", + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(15, TimeUnit.SECONDS); + JsonNode resumeParams = server.awaitResume(); + assertFalse(resumeParams.has("enableGitHubTelemetryRedirection"), + "resume request should omit the flag when no handler is registered"); + } + } + + @Test + void optionsRetainAndCloneTelemetryHandler() { + Consumer handler = n -> { + }; + var options = new CopilotClientOptions().setOnGitHubTelemetry(handler); + assertSame(handler, options.getOnGitHubTelemetry()); + + var copy = options.clone(); + assertSame(handler, copy.getOnGitHubTelemetry()); + } + + /** + * A minimal in-process JSON-RPC runtime that answers the connect/create/resume + * handshake so a real {@link CopilotClient} can be driven over a socket, and + * can push {@code gitHubTelemetry.event} notifications back to the client. + */ + private static final class FakeRuntimeServer implements AutoCloseable { + + private final ServerSocket serverSocket; + private final Thread acceptThread; + private final CompletableFuture ready = new CompletableFuture<>(); + private final CompletableFuture createParams = new CompletableFuture<>(); + private final CompletableFuture resumeParams = new CompletableFuture<>(); + + FakeRuntimeServer() throws IOException { + serverSocket = new ServerSocket(0); + acceptThread = new Thread(this::acceptLoop, "fake-runtime-accept"); + acceptThread.setDaemon(true); + acceptThread.start(); + } + + String url() { + return "127.0.0.1:" + serverSocket.getLocalPort(); + } + + JsonNode awaitCreate() throws Exception { + return createParams.get(15, TimeUnit.SECONDS); + } + + JsonNode awaitResume() throws Exception { + return resumeParams.get(15, TimeUnit.SECONDS); + } + + void sendTelemetry(Object params) throws Exception { + ready.get(15, TimeUnit.SECONDS).notify("gitHubTelemetry.event", params); + } + + private void acceptLoop() { + try { + Socket socket = serverSocket.accept(); + JsonRpcClient server = JsonRpcClient.fromSocket(socket); + server.registerMethodHandler("connect", + (id, params) -> respond(server, id, Map.of("protocolVersion", 2))); + server.registerMethodHandler("session.create", (id, params) -> { + createParams.complete(params); + respond(server, id, Map.of("sessionId", params.path("sessionId").asText("created"), "workspacePath", + "/workspace")); + }); + server.registerMethodHandler("session.resume", (id, params) -> { + resumeParams.complete(params); + respond(server, id, Map.of("sessionId", params.path("sessionId").asText("resume-1"), + "workspacePath", "/workspace")); + }); + server.registerMethodHandler("session.destroy", (id, params) -> respond(server, id, Map.of())); + server.registerMethodHandler("runtime.shutdown", (id, params) -> respond(server, id, Map.of())); + ready.complete(server); + } catch (IOException e) { + ready.completeExceptionally(e); + createParams.completeExceptionally(e); + resumeParams.completeExceptionally(e); + } + } + + private static void respond(JsonRpcClient server, String id, Object result) { + if (id == null) { + return; + } + try { + server.sendResponse(id, result); + } catch (IOException e) { + // Connection torn down (e.g. client closing); ignore. + } + } + + @Override + public void close() throws Exception { + JsonRpcClient server = ready.getNow(null); + if (server != null) { + server.close(); + } + serverSocket.close(); + } + } +} From d1fb55a2f48eea74e5290576c5c8bce58efd89f9 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 29 Jun 2026 15:19:59 -0700 Subject: [PATCH 8/8] Fix telemetry codegen after schema bump Keep experimental GitHub telemetry schema additions available to codegen until they ship in the packaged Copilot schema, and apply formatter output required by CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 6 +- nodejs/test/client.test.ts | 5 +- python/copilot/_jsonrpc.py | 4 +- python/copilot/client.py | 4 +- python/test_client.py | 5 +- rust/src/router.rs | 18 +- scripts/codegen/rust.ts | 10 +- .../api-additions.schema.json | 166 ++++++++++++++++++ scripts/codegen/utils.ts | 57 +++++- 9 files changed, 244 insertions(+), 31 deletions(-) create mode 100644 scripts/codegen/schema-overrides/api-additions.schema.json diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 761211423f..72f6f574ea 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -33,7 +33,11 @@ import { registerClientGlobalApiHandlers, registerClientSessionApiHandlers, } from "./generated/rpc.js"; -import type { GitHubTelemetryNotification, OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; +import type { + GitHubTelemetryNotification, + OpenCanvasInstance, + SessionUpdateOptionsParams, +} from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a7c4aea69a..da241a65e7 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -236,9 +236,8 @@ describe("CopilotClient", () => { }); it("dispatches a real gitHubTelemetry.event wire notification to the handler", async () => { - const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = await import( - "vscode-jsonrpc/node.js" - ); + const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = + await import("vscode-jsonrpc/node.js"); const { registerClientGlobalApiHandlers } = await import("../src/generated/rpc.js"); const clientToServer = new PassThrough(); diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index 5e799149e0..58f75b6ed1 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -233,9 +233,7 @@ def set_notification_handler(self, handler: Callable[[str, dict], None]): """Set the handler for incoming notifications from the server.""" self.notification_handler = handler - def set_notification_method_handler( - self, method: str, handler: Callable[[dict], Any] | None - ): + def set_notification_method_handler(self, method: str, handler: Callable[[dict], Any] | None): """Register a handler for a specific server-to-client notification method. Notifications carry no ``id`` and expect no response, so they are diff --git a/python/copilot/client.py b/python/copilot/client.py index 0560741d18..d8ea14f5fb 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -395,9 +395,7 @@ class _GitHubTelemetryAdapter: ``GitHubTelemetryHandler`` protocol. """ - def __init__( - self, callback: Callable[[GitHubTelemetryNotification], None] - ) -> None: + def __init__(self, callback: Callable[[GitHubTelemetryNotification], None]) -> None: self._callback = callback async def event(self, params: GitHubTelemetryNotification) -> None: diff --git a/python/test_client.py b/python/test_client.py index 4241a77aa0..251450d50b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -2094,10 +2094,7 @@ async def test_event_handler_not_registered_without_option(self): await client.start() try: - assert ( - "gitHubTelemetry.event" - not in client._client.notification_method_handlers - ) + assert "gitHubTelemetry.event" not in client._client.notification_method_handlers assert "gitHubTelemetry.event" not in client._client.request_handlers finally: await client.force_stop() diff --git a/rust/src/router.rs b/rust/src/router.rs index cbd900d2d6..adc1923824 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -114,17 +114,17 @@ impl SessionRouter { >(params.clone()) { Ok(telemetry) => { - if std::panic::catch_unwind(std::panic::AssertUnwindSafe( - || callback(telemetry), - )) - .is_err() - { - warn!( - "gitHubTelemetry.event callback panicked; \ + if std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || callback(telemetry), + )) + .is_err() + { + warn!( + "gitHubTelemetry.event callback panicked; \ continuing notification routing" - ); + ); + } } - } Err(e) => { warn!( error = %e, diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index 3a3ce39a2f..3d6208204e 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -40,7 +40,7 @@ import { isSchemaExperimental, isSchemaInternal, isVoidSchema, - normalizeSchemaBrandCasing, + loadSchemaJson, fixBrandCasing, parseExternalSchemaRef, postProcessSchema, @@ -2155,12 +2155,8 @@ async function generate(): Promise { schemaArgs.sessionEventsSchemaPath || (await getSessionEventsSchemaPath()); const apiSchemaPath = await getApiSchemaPath(schemaArgs.apiSchemaPath); - const sessionEventsRaw = normalizeSchemaBrandCasing( - JSON.parse(await fs.readFile(sessionEventsSchemaPath, "utf-8")), - ); - const apiRaw = normalizeSchemaBrandCasing( - JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema, - ); + const sessionEventsRaw = await loadSchemaJson(sessionEventsSchemaPath); + const apiRaw = await loadSchemaJson(apiSchemaPath); const sessionEventsSchema = propagateInternalVisibility( postProcessSchema( diff --git a/scripts/codegen/schema-overrides/api-additions.schema.json b/scripts/codegen/schema-overrides/api-additions.schema.json new file mode 100644 index 0000000000..b5f2fd70d6 --- /dev/null +++ b/scripts/codegen/schema-overrides/api-additions.schema.json @@ -0,0 +1,166 @@ +{ + "clientGlobal": { + "gitHubTelemetry": { + "event": { + "rpcMethod": "gitHubTelemetry.event", + "description": "Forwards a single GitHub telemetry event to a host connection that opted into telemetry redirection for the session.", + "params": { + "$ref": "#/definitions/GitHubTelemetryNotification", + "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session." + }, + "result": { + "type": "null" + }, + "notification": true, + "stability": "experimental" + } + } + }, + "definitions": { + "GitHubTelemetryClientInfo": { + "type": "object", + "properties": { + "cli_version": { + "type": "string", + "description": "Copilot CLI version string." + }, + "os_platform": { + "type": "string", + "description": "Operating system platform (e.g. darwin, linux, win32)." + }, + "os_version": { + "type": "string", + "description": "Operating system version string." + }, + "os_arch": { + "type": "string", + "description": "Operating system architecture (e.g. arm64, x64)." + }, + "node_version": { + "type": "string", + "description": "Node.js runtime version string." + }, + "copilot_plan": { + "type": "string", + "description": "Copilot subscription plan, when known." + }, + "client_type": { + "type": "string", + "description": "Type of client." + }, + "client_name": { + "type": "string", + "description": "Name of the client application." + }, + "is_staff": { + "type": "boolean", + "description": "Whether the user is a GitHub/Microsoft staff member." + }, + "dev_device_id": { + "type": "string", + "description": "Stable machine identifier for the device." + } + }, + "required": [ + "cli_version", + "os_platform", + "os_version", + "os_arch", + "node_version" + ], + "additionalProperties": false, + "description": "Client environment metadata describing the process that produced a telemetry event.", + "title": "GitHubTelemetryClientInfo", + "stability": "experimental" + }, + "GitHubTelemetryEvent": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "description": "Event type/kind (e.g. get_completion_with_tools_turn, tool_call_executed)." + }, + "created_at": { + "type": "string", + "description": "Timestamp when the event was created (ISO 8601 format)." + }, + "model_call_id": { + "type": "string", + "description": "Reference to the model call that produced this event." + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "String-valued properties as a map from key to value." + }, + "metrics": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Numeric metrics as a map from key to value." + }, + "exp_assignment_context": { + "type": "string", + "description": "Experiment assignment context." + }, + "features": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Feature flags enabled for this session, as a map from flag to value." + }, + "session_id": { + "type": "string", + "description": "Session identifier the event belongs to." + }, + "copilot_tracking_id": { + "type": "string", + "description": "Copilot tracking ID for user-level attribution." + }, + "client": { + "$ref": "#/definitions/GitHubTelemetryClientInfo", + "description": "Client environment metadata." + } + }, + "required": [ + "kind", + "properties", + "metrics" + ], + "additionalProperties": false, + "description": "A single telemetry event in the runtime's native GitHub-shaped telemetry format, forwarded verbatim to opted-in hosts. The `restricted` flag on the enclosing GitHubTelemetryNotification distinguishes standard from restricted events; the payload shape is identical for both.", + "title": "GitHubTelemetryEvent", + "stability": "experimental" + }, + "GitHubTelemetryNotification": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session the telemetry event belongs to." + }, + "restricted": { + "type": "boolean", + "description": "Whether this is a restricted telemetry event (cli.restricted_telemetry). Hosts must route restricted events to first-party Microsoft stores only." + }, + "event": { + "$ref": "#/definitions/GitHubTelemetryEvent", + "description": "The telemetry event, in the runtime's native GitHub-shaped telemetry format." + } + }, + "required": [ + "sessionId", + "restricted", + "event" + ], + "additionalProperties": false, + "description": "Payload for a `gitHubTelemetry.event` notification: a single GitHub telemetry event the runtime forwards to a host connection that opted into telemetry redirection for the session.", + "title": "GitHubTelemetryNotification", + "stability": "experimental" + } + } +} diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 9ab335b05f..37f191edb3 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -46,6 +46,7 @@ export type SchemaWithSharedDefinitions = T // ── Schema paths ──────────────────────────────────────────────────────────── const SDK_NODE_MODULES = path.join(REPO_ROOT, "nodejs/node_modules"); +const API_SCHEMA_ADDITIONS_PATH = path.join(__dirname, "schema-overrides/api-additions.schema.json"); /** * Resolve a JSON schema shipped by the `@github/copilot` CLI package. @@ -185,7 +186,61 @@ function renameBrandDefinitionKeys(defs: Record): void { /** Load a JSON schema file and normalize GitHub brand casing in titles, refs, and definition keys. */ export async function loadSchemaJson(filePath: string): Promise { const parsed = JSON.parse(await fs.readFile(filePath, "utf-8")) as T; - return normalizeSchemaBrandCasing(parsed); + const normalized = normalizeSchemaBrandCasing(parsed); + return applyApiSchemaAdditions(normalized, filePath); +} + +async function applyApiSchemaAdditions(schema: T, filePath: string): Promise { + if (path.basename(filePath) !== "api.schema.json") return schema; + + let additions: ApiSchema; + try { + additions = normalizeSchemaBrandCasing( + JSON.parse(await fs.readFile(API_SCHEMA_ADDITIONS_PATH, "utf-8")) as ApiSchema + ); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") return schema; + throw err; + } + + const apiSchema = schema as ApiSchema; + mergeSchemaAdditions(apiSchema, "definitions", additions.definitions); + mergeSchemaAdditions(apiSchema, "$defs", additions.$defs); + mergeSchemaAdditions(apiSchema, "server", additions.server); + mergeSchemaAdditions(apiSchema, "session", additions.session); + mergeSchemaAdditions(apiSchema, "clientSession", additions.clientSession); + mergeSchemaAdditions(apiSchema, "clientGlobal", additions.clientGlobal); + return schema; +} + +function mergeSchemaAdditions( + schema: ApiSchema, + key: keyof ApiSchema, + additions: Record | undefined +): void { + if (!additions) return; + mergeMissingEntries((schema[key] ??= {}) as Record, additions); +} + +function mergeMissingEntries(target: Record, additions: Record | undefined): void { + if (!additions) return; + + for (const [key, value] of Object.entries(additions)) { + if (!(key in target)) { + target[key] = value; + continue; + } + + const existing = target[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + mergeMissingEntries(existing, value); + } + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } // ── Schema processing ───────────────────────────────────────────────────────