diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a67eb9681..aaff9005f 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 e5d830ada..47f62dbf7 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -11456,6 +11456,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. [Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -21657,11 +21764,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. @@ -21685,6 +21805,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); } } @@ -22081,6 +22206,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 5ae965781..aa69f2a06 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 000000000..465791f64 --- /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 diff --git a/go/client.go b/go/client.go index 970f04642..891cb11e7 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 d59c71c6f..f39f99ece 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 69569144f..06f0b41d3 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1982,6 +1982,76 @@ type GitHubRepoRef struct { Owner string `json:"owner"` } +// 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 @@ -18425,6 +18495,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 { @@ -18461,7 +18545,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 { @@ -18478,6 +18563,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 8a7df3c46..2e503af1e 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/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 8473b3bb4..eb803c3df 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 000000000..3589eb15d --- /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 e9f59aa64..2c2897e73 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 8fc966c6f..f498c06c6 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 000000000..abf1600d2 --- /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 000000000..f4e353e37 --- /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 000000000..637c84b4f --- /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 2b25875d7..3f54cd7da 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 000000000..d894ba90c --- /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(); + } + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 53686a6ca..72f6f574e 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -33,7 +33,11 @@ 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 +518,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 +639,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 +757,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 +1435,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 +1642,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 +2560,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 65df34640..90f6029c4 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -14334,6 +14334,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 * @@ -16821,9 +16940,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; } /** @@ -16847,4 +16978,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 eebf9add5..e05b33c15 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 e354bd821..a050c3abd 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 9612e4ca2..da241a65e 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,135 @@ 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/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index a58908d08..58f75b6ed 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,19 @@ 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 +411,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 +446,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 c7d11d12b..d8ea14f5f 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,18 @@ 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 +423,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 +1113,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 +1158,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 +1192,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 +1208,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 +2001,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 +2594,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 +3663,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 +3783,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 +3856,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 1d3a7b246..fb1deb8ee 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1798,6 +1798,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: @@ -21276,6 +21347,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: @@ -22083,6 +22262,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 @@ -22879,6 +23061,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")) @@ -23541,7 +23726,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_shell_exit, 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_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, 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_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, 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_visibility_status, 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, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_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_shell_exit, 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_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, 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_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, 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_visibility_status, 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, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_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 = {} @@ -23675,6 +23860,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) @@ -26598,9 +26786,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", @@ -26626,6 +26821,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", @@ -26786,6 +26988,10 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: "GhCLIAuthInfo", "GhCLIAuthInfoType", "GitHubAuthApi", + "GitHubTelemetryClientInfo", + "GitHubTelemetryEvent", + "GitHubTelemetryHandler", + "GitHubTelemetryNotification", "HMACAuthInfo", "HMACAuthInfoType", "HandlePendingToolCallRequest", diff --git a/python/test_client.py b/python/test_client.py index f3f46c4d8..251450d50 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1921,3 +1921,180 @@ 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/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 1e6caa15e..8a3e09928 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -14401,6 +14401,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. /// ///
diff --git a/rust/src/github_telemetry.rs b/rust/src/github_telemetry.rs new file mode 100644 index 000000000..509035a22 --- /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 22fdc53d7..136def857 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 cc621c287..adc192382 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 18b91b437..08b7215c6 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 06b97fbbd..3a92bbd85 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 e6dad66d5..ba5141f68 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 98c624823..ff8f7f322 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| { diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 5403fb444..fec25953d 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 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}`); diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index 3a3ce39a2..3d6208204 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 000000000..b5f2fd70d --- /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/typescript.ts b/scripts/codegen/typescript.ts index 1303a4979..497c909ea 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");`); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index c63f9732c..37f191edb 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 ─────────────────────────────────────────────────────── @@ -383,6 +438,7 @@ export interface RpcMethod { stability?: string; visibility?: string; deprecated?: boolean; + notification?: boolean; } export function getRpcSchemaTypeName(schema: JSONSchema7 | null | undefined, fallback: string): string {