From c2f08ff199d6a04908158a66803a6854b8de224d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 09:25:10 +0000 Subject: [PATCH 01/21] docs: update version references to 1.0.13-preview.2 --- java/README.md | 8 ++++---- java/sdk/jbang-example.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/java/README.md b/java/README.md index 10eb72ce50..2e1ba51812 100644 --- a/java/README.md +++ b/java/README.md @@ -32,14 +32,14 @@ Replace `${copilot.sdk.version}` with the latest release from Maven Central. com.github copilot-sdk-java - 1.0.13-preview.1 + 1.0.13-preview.2 ``` ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.13-preview.1' +implementation 'com.github:copilot-sdk-java:1.0.13-preview.2' ``` #### Snapshot Builds @@ -58,7 +58,7 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.14-preview.1-SNAPSHOT + 1.0.14-preview.2-SNAPSHOT ``` @@ -67,7 +67,7 @@ Snapshot builds of the next development version are published to Maven Central S Replace `${copilot.sdk.version}` with the latest release from Maven Central. ```groovy -implementation 'com.github:copilot-sdk-java:1.0.14-preview.1-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.14-preview.2-SNAPSHOT' ``` ## In-process mode (experimental) diff --git a/java/sdk/jbang-example.java b/java/sdk/jbang-example.java index cf091d3c12..6cb04215ac 100644 --- a/java/sdk/jbang-example.java +++ b/java/sdk/jbang-example.java @@ -1,5 +1,5 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS com.github:copilot-sdk-java:1.0.13-preview.1 +//DEPS com.github:copilot-sdk-java:1.0.13-preview.2 import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; From a94a1f2ddd7e015b3d3f7bbc94c7c2b1a28219e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 09:25:41 +0000 Subject: [PATCH 02/21] [maven-release-plugin] prepare release java/v1.0.13-preview.2 --- java/copilot-native/pom.xml | 4 ++-- java/pom.xml | 4 ++-- java/sdk/pom.xml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml index f3694457d8..e0db057863 100644 --- a/java/copilot-native/pom.xml +++ b/java/copilot-native/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.1-SNAPSHOT + 1.0.13-preview.2 ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.2 diff --git a/java/pom.xml b/java/pom.xml index c5a17d66d8..dfa6bb7484 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.1-SNAPSHOT + 1.0.13-preview.2 pom GitHub Copilot SDK :: Java :: Parent @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.2 diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index 4f9ece6354..e0265813c5 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.1-SNAPSHOT + 1.0.13-preview.2 ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.2 From 50ce37e19258524c6de82651652971e96d7ae5f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 09:25:45 +0000 Subject: [PATCH 03/21] [maven-release-plugin] prepare for next development iteration --- java/copilot-native/pom.xml | 4 ++-- java/pom.xml | 4 ++-- java/sdk/pom.xml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml index e0db057863..d138a0e56b 100644 --- a/java/copilot-native/pom.xml +++ b/java/copilot-native/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.2 + 1.0.14-preview.2-SNAPSHOT ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.2 + HEAD diff --git a/java/pom.xml b/java/pom.xml index dfa6bb7484..3e5e1645a5 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.2 + 1.0.14-preview.2-SNAPSHOT pom GitHub Copilot SDK :: Java :: Parent @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.2 + HEAD diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index e0265813c5..811a05db67 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.2 + 1.0.14-preview.2-SNAPSHOT ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.2 + HEAD From 53efb3593e65e5f04474099ffd3e00efa7777482 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:06:49 +0000 Subject: [PATCH 04/21] Update @github/copilot to 1.0.82 (#2442) * Update @github/copilot to 1.0.82 - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code * Fix generated schema compatibility regressions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Stephen Toub Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 73 ++++++++- dotnet/src/Generated/SessionEvents.cs | 151 ++++++++++++++++++ go/rpc/zrpc.go | 58 +++++++ go/rpc/zsession_events.go | 21 +++ go/zsession_events.go | 7 + java/pom.xml | 2 +- java/scripts/codegen/package-lock.json | 72 ++++----- java/scripts/codegen/package.json | 2 +- .../AssistantMessageToolRequest.java | 4 +- .../AssistantMessageToolRequestCaller.java | 29 ++++ ...AssistantMessageToolRequestCallerType.java | 33 ++++ .../github/copilot/generated/AutoTier.java | 37 +++++ .../copilot/generated/SessionResumeEvent.java | 2 + .../copilot/generated/SessionStartEvent.java | 2 + .../copilot/generated/rpc/AutoTier.java | 37 +++++ .../generated/rpc/CapiSessionOptions.java | 2 + .../rpc/SessionCommandsEnqueueParams.java | 4 +- .../copilot/generated/rpc/SessionRpc.java | 3 + .../generated/rpc/SessionSandboxApi.java | 42 +++++ ...sionSandboxGetEnforcementStatusParams.java | 30 ++++ ...sionSandboxGetEnforcementStatusResult.java | 34 ++++ .../copilot/SessionEventHandlingTest.java | 4 +- nodejs/package-lock.json | 54 +++---- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/rpc.ts | 38 ++++- nodejs/src/generated/session-events.ts | 27 ++++ nodejs/test/session-event-codegen.test.ts | 29 +++- python/copilot/generated/rpc.py | 97 +++++++---- python/copilot/generated/session_events.py | 56 +++++++ rust/src/generated/api_types.rs | 72 ++++++++- rust/src/generated/rpc.rs | 43 +++++ rust/src/generated/session_events.rs | 48 ++++++ rust/tests/e2e/commands.rs | 1 + rust/tests/e2e/rpc_queue.rs | 3 + scripts/codegen/python.ts | 12 +- test/harness/package-lock.json | 54 +++---- test/harness/package.json | 2 +- 38 files changed, 1051 insertions(+), 138 deletions(-) create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCaller.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCallerType.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/AutoTier.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/AutoTier.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusResult.java diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index f3c1a57e6e..c97660f369 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -5393,6 +5393,32 @@ internal sealed class LogRequest public string? Url { get; set; } } +/// Managed sandbox enforcement state for a session. +[Experimental(Diagnostics.Experimental)] +public sealed class SandboxEnforcementStatus +{ + /// Whether an enforcement failure has permanently blocked the session. + [JsonPropertyName("blocked")] + public bool Blocked { get; set; } + + /// The first sandbox enforcement failure that blocked the session. + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + /// Whether the effective managed policy requires an available sandbox backend. + [JsonPropertyName("required")] + public bool Required { get; set; } +} + +/// Identifies the target session. +[Experimental(Diagnostics.Experimental)] +internal sealed class SessionSandboxGetEnforcementStatusRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Authentication status and account metadata for the session. [Experimental(Diagnostics.Experimental)] public sealed class SessionAuthStatus @@ -10933,6 +10959,10 @@ public sealed class OptionsUpdateAdditionalContentExclusionPolicy [Experimental(Diagnostics.Experimental)] public sealed class CapiSessionOptions { + /// Routing preference used when the session model is `auto`. The runtime persists the preference across cold resume. When omitted, the default routing behavior is used. Resuming an already-resident session cannot change its preference. + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } + /// Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. [JsonPropertyName("enableWebSocketResponses")] public bool? EnableWebSocketResponses { get; set; } @@ -13395,6 +13425,10 @@ internal sealed class EnqueueCommandParams [JsonPropertyName("command")] public string Command { get; set; } = string.Empty; + /// Optional user-facing text for the queue row. The command string is shown when omitted. + [JsonPropertyName("displayText")] + public string? DisplayText { get; set; } + /// Target session identifier. [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -30941,6 +30975,12 @@ internal SessionRpc(CopilotSession session) internal CopilotSession Session => _session; + /// Sandbox APIs. + public SandboxApi Sandbox => + field ?? + Interlocked.CompareExchange(ref field, new(_session), null) ?? + field; + /// GitHubAuth APIs. public GitHubAuthApi GitHubAuth => field ?? @@ -31313,6 +31353,29 @@ public async Task LogAsync(string message, SessionLogLevel? level = n } } +/// Provides session-scoped Sandbox APIs. +[Experimental(Diagnostics.Experimental)] +public sealed class SandboxApi +{ + private readonly CopilotSession _session; + + internal SandboxApi(CopilotSession session) + { + _session = session; + } + + /// Returns whether managed policy requires sandbox enforcement and whether an enforcement failure has permanently blocked the session. + /// The to monitor for cancellation requests. The default is . + /// Managed sandbox enforcement state for a session. + public async Task GetEnforcementStatusAsync(CancellationToken cancellationToken = default) + { + _session.ThrowIfDisposed(); + + var request = new SessionSandboxGetEnforcementStatusRequest { SessionId = _session.SessionId }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.sandbox.getEnforcementStatus", [request], cancellationToken); + } +} + /// Provides session-scoped GitHubAuth APIs. [Experimental(Diagnostics.Experimental)] public sealed class GitHubAuthApi @@ -33687,14 +33750,15 @@ public async Task ExecuteAsync(string commandName, string /// Enqueues a slash command for FIFO processing on the local session. /// Slash-prefixed command string to enqueue, e.g. '/compact' or '/model gpt-4'. Queued FIFO with any in-flight items; if the session is idle, processing kicks off immediately. + /// Optional user-facing text for the queue row. The command string is shown when omitted. /// The to monitor for cancellation requests. The default is . /// Indicates whether the command was accepted into the local execution queue. - public async Task EnqueueAsync(string command, CancellationToken cancellationToken = default) + public async Task EnqueueAsync(string command, string? displayText = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(command); _session.ThrowIfDisposed(); - var request = new EnqueueCommandParams { SessionId = _session.SessionId, Command = command }; + var request = new EnqueueCommandParams { SessionId = _session.SessionId, Command = command, DisplayText = displayText }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.commands.enqueue", [request], cancellationToken); } @@ -35592,6 +35656,8 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.AssistantMessageStartData), TypeInfoPropertyName = "SessionEventsAssistantMessageStartData")] [JsonSerializable(typeof(GitHub.Copilot.AssistantMessageStartEvent), TypeInfoPropertyName = "SessionEventsAssistantMessageStartEvent")] [JsonSerializable(typeof(GitHub.Copilot.AssistantMessageToolRequest), TypeInfoPropertyName = "SessionEventsAssistantMessageToolRequest")] +[JsonSerializable(typeof(GitHub.Copilot.AssistantMessageToolRequestCaller), TypeInfoPropertyName = "SessionEventsAssistantMessageToolRequestCaller")] +[JsonSerializable(typeof(GitHub.Copilot.AssistantMessageToolRequestCallerType), TypeInfoPropertyName = "SessionEventsAssistantMessageToolRequestCallerType")] [JsonSerializable(typeof(GitHub.Copilot.AssistantMessageToolRequestType), TypeInfoPropertyName = "SessionEventsAssistantMessageToolRequestType")] [JsonSerializable(typeof(GitHub.Copilot.AssistantReasoningData), TypeInfoPropertyName = "SessionEventsAssistantReasoningData")] [JsonSerializable(typeof(GitHub.Copilot.AssistantReasoningDeltaData), TypeInfoPropertyName = "SessionEventsAssistantReasoningDeltaData")] @@ -35646,6 +35712,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchRequestedData), TypeInfoPropertyName = "SessionEventsAutoModeSwitchRequestedData")] [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchRequestedEvent), TypeInfoPropertyName = "SessionEventsAutoModeSwitchRequestedEvent")] [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchResponse), TypeInfoPropertyName = "SessionEventsAutoModeSwitchResponse")] +[JsonSerializable(typeof(GitHub.Copilot.AutoTier), TypeInfoPropertyName = "SessionEventsAutoTier")] [JsonSerializable(typeof(GitHub.Copilot.AutopilotObjectiveChangedOperation), TypeInfoPropertyName = "SessionEventsAutopilotObjectiveChangedOperation")] [JsonSerializable(typeof(GitHub.Copilot.AutopilotObjectiveChangedStatus), TypeInfoPropertyName = "SessionEventsAutopilotObjectiveChangedStatus")] [JsonSerializable(typeof(GitHub.Copilot.BinaryAssetReference), TypeInfoPropertyName = "SessionEventsBinaryAssetReference")] @@ -36484,6 +36551,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(SandboxConfigUserPolicyNetwork))] [JsonSerializable(typeof(SandboxConfigUserPolicyNetworkProxy))] [JsonSerializable(typeof(SandboxConfigUserPolicySeatbelt))] +[JsonSerializable(typeof(SandboxEnforcementStatus))] [JsonSerializable(typeof(ScheduleAddAtRequest))] [JsonSerializable(typeof(ScheduleAddCronRequest))] [JsonSerializable(typeof(ScheduleAddRequest))] @@ -36620,6 +36688,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(SessionQueueRemoveMostRecentRequest))] [JsonSerializable(typeof(SessionQueueSnapshotRequest))] [JsonSerializable(typeof(SessionRemoteDisableRequest))] +[JsonSerializable(typeof(SessionSandboxGetEnforcementStatusRequest))] [JsonSerializable(typeof(SessionScheduleHasSelfPacedRequest))] [JsonSerializable(typeof(SessionScheduleHydrateRequest))] [JsonSerializable(typeof(SessionScheduleListRequest))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index b4351c8f2e..4c7ebc6ffa 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -1916,6 +1916,11 @@ public sealed partial class SessionStartData [JsonPropertyName("alreadyInUse")] public bool? AlreadyInUse { get; set; } + /// Auto routing preference selected at session creation time. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } + /// Working directory and git context at session start. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("context")] @@ -1995,6 +2000,11 @@ public sealed partial class SessionResumeData [JsonPropertyName("alreadyInUse")] public bool? AlreadyInUse { get; set; } + /// Auto routing preference active at resume time. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } + /// Updated working directory and git context at resume time. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("context")] @@ -7098,6 +7108,19 @@ public sealed partial class AssistantMessageServerTools public JsonElement[]? RawContentBlocks { get; set; } } +/// Hosted program that requested this client tool call. +/// Nested data type for AssistantMessageToolRequestCaller. +public sealed partial class AssistantMessageToolRequestCaller +{ + /// Provider-assigned identifier for the hosted caller. + [JsonPropertyName("callerId")] + public required string CallerId { get; set; } + + /// Kind of hosted caller that requested the client tool call. + [JsonPropertyName("type")] + public required AssistantMessageToolRequestCallerType Type { get; set; } +} + /// A tool invocation request from the assistant. /// Nested data type for AssistantMessageToolRequest. public sealed partial class AssistantMessageToolRequest @@ -7107,6 +7130,11 @@ public sealed partial class AssistantMessageToolRequest [JsonPropertyName("arguments")] public JsonElement? Arguments { get; set; } + /// Hosted program that requested this client tool call. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("caller")] + public AssistantMessageToolRequestCaller? Caller { get; set; } + /// Resolved intention summary describing what this specific call does. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("intentionSummary")] @@ -10305,6 +10333,70 @@ public sealed partial class McpAppToolCallCompleteToolMeta public McpAppToolCallCompleteToolMetaUI? Ui { get; set; } } +/// Routing preference used when the session model is `auto`. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct AutoTier : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public AutoTier(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Optimize for efficiency. + public static AutoTier Efficiency { get; } = new("efficiency"); + + /// Balance efficiency and intelligence. + public static AutoTier Balance { get; } = new("balance"); + + /// Optimize for intelligence. + public static AutoTier Intelligence { get; } = new("intelligence"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(AutoTier left, AutoTier right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(AutoTier left, AutoTier right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is AutoTier other && Equals(other); + + /// + public bool Equals(AutoTier other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override AutoTier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, AutoTier value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(AutoTier)); + } + } +} + /// Hosting platform type of the repository (github or ado). [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -12374,6 +12466,64 @@ public override void Write(Utf8JsonWriter writer, CitationProvider value, JsonSe } } +/// Hosted program caller type. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct AssistantMessageToolRequestCallerType : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public AssistantMessageToolRequestCallerType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the program value. + public static AssistantMessageToolRequestCallerType Program { get; } = new("program"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(AssistantMessageToolRequestCallerType left, AssistantMessageToolRequestCallerType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(AssistantMessageToolRequestCallerType left, AssistantMessageToolRequestCallerType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is AssistantMessageToolRequestCallerType other && Equals(other); + + /// + public bool Equals(AssistantMessageToolRequestCallerType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override AssistantMessageToolRequestCallerType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, AssistantMessageToolRequestCallerType value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(AssistantMessageToolRequestCallerType)); + } + } +} + /// API endpoint used for this model call, matching CAPI supported_endpoints vocabulary. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -15438,6 +15588,7 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(AssistantMessageStartData))] [JsonSerializable(typeof(AssistantMessageStartEvent))] [JsonSerializable(typeof(AssistantMessageToolRequest))] +[JsonSerializable(typeof(AssistantMessageToolRequestCaller))] [JsonSerializable(typeof(AssistantReasoningData))] [JsonSerializable(typeof(AssistantReasoningDeltaData))] [JsonSerializable(typeof(AssistantReasoningDeltaEvent))] diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index edd5f925fe..706f32bf5f 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1361,6 +1361,10 @@ type CanvasSessionContext struct { // Experimental: CapiSessionOptions is part of an experimental API and may change or be // removed. type CapiSessionOptions struct { + // Routing preference used when the session model is `auto`. The runtime persists the + // preference across cold resume. When omitted, the default routing behavior is used. + // Resuming an already-resident session cannot change its preference. + AutoTier *AutoTier `json:"autoTier,omitempty"` // Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when // the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses // transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting @@ -2599,6 +2603,8 @@ type EnqueueCommandParams struct { // Slash-prefixed command string to enqueue, e.g. '/compact' or '/model gpt-4'. Queued FIFO // with any in-flight items; if the session is idle, processing kicks off immediately. Command string `json:"command"` + // Optional user-facing text for the queue row. The command string is shown when omitted. + DisplayText *string `json:"displayText,omitempty"` } // Indicates whether the command was accepted into the local execution queue. @@ -10343,6 +10349,18 @@ type SandboxConfigUserPolicySeatbelt struct { KeychainAccess *bool `json:"keychainAccess,omitempty"` } +// Managed sandbox enforcement state for a session. +// Experimental: SandboxEnforcementStatus is part of an experimental API and may change or +// be removed. +type SandboxEnforcementStatus struct { + // Whether an enforcement failure has permanently blocked the session. + Blocked bool `json:"blocked"` + // The first sandbox enforcement failure that blocked the session. + Reason *string `json:"reason,omitempty"` + // Whether the effective managed policy requires an available sandbox backend. + Required bool `json:"required"` +} + // Register an absolute-time scheduled prompt. // Experimental: ScheduleAddAtRequest is part of an experimental API and may change or be // removed. @@ -15858,6 +15876,19 @@ const ( AuthInfoTypeUser AuthInfoType = "user" ) +// Routing preference used when the session model is `auto`. +// Experimental: AutoTier is part of an experimental API and may change or be removed. +type AutoTier string + +const ( + // Balance efficiency and intelligence. + AutoTierBalance AutoTier = "balance" + // Optimize for efficiency. + AutoTierEfficiency AutoTier = "efficiency" + // Optimize for intelligence. + AutoTierIntelligence AutoTier = "intelligence" +) + // Custom input-format kind. // Experimental: BuiltinToolFormatType is part of an experimental API and may change or be // removed. @@ -21250,6 +21281,9 @@ func (a *CommandsAPI) Enqueue(ctx context.Context, params *EnqueueCommandParams) req := map[string]any{"sessionId": a.sessionID} if params != nil { req["command"] = params.Command + if params.DisplayText != nil { + req["displayText"] = *params.DisplayText + } } raw, err := a.client.Request(ctx, "session.commands.enqueue", req) if err != nil { @@ -25037,6 +25071,28 @@ func (a *RemoteAPI) NotifySteerableChanged(ctx context.Context, params *RemoteNo return &result, nil } +// Experimental: SandboxAPI contains experimental APIs that may change or be removed. +type SandboxAPI sessionAPI + +// GetEnforcementStatus returns whether managed policy requires sandbox enforcement and +// whether an enforcement failure has permanently blocked the session. +// +// RPC method: session.sandbox.getEnforcementStatus. +// +// Returns: Managed sandbox enforcement state for a session. +func (a *SandboxAPI) GetEnforcementStatus(ctx context.Context) (*SandboxEnforcementStatus, error) { + req := map[string]any{"sessionId": a.sessionID} + raw, err := a.client.Request(ctx, "session.sandbox.getEnforcementStatus", req) + if err != nil { + return nil, err + } + var result SandboxEnforcementStatus + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Experimental: ScheduleAPI contains experimental APIs that may change or be removed. type ScheduleAPI sessionAPI @@ -26586,6 +26642,7 @@ type SessionRPC struct { Provider *ProviderAPI Queue *QueueAPI Remote *RemoteAPI + Sandbox *SandboxAPI Schedule *ScheduleAPI Shell *ShellAPI Skills *SkillsAPI @@ -26907,6 +26964,7 @@ func NewSessionRPC(client *jsonrpc2.Client, sessionID string) *SessionRPC { r.Provider = (*ProviderAPI)(&r.common) r.Queue = (*QueueAPI)(&r.common) r.Remote = (*RemoteAPI)(&r.common) + r.Sandbox = (*SandboxAPI)(&r.common) r.Schedule = (*ScheduleAPI)(&r.common) r.Shell = (*ShellAPI)(&r.common) r.Skills = (*SkillsAPI)(&r.common) diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 7f24ab6283..07504e8815 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -2128,6 +2128,8 @@ func (*SessionHandoffData) Type() SessionEventType { return SessionEventTypeSess type SessionStartData struct { // Whether the session was already in use by another client at start time AlreadyInUse *bool `json:"alreadyInUse,omitempty"` + // Auto routing preference selected at session creation time + AutoTier *AutoTier `json:"autoTier,omitempty"` // Working directory and git context at session start Context *WorkingDirectoryContext `json:"context,omitempty"` // Context tier selected at session creation time for models with tiered context pricing; null when no tier is selected (e.g., non-tiered model) @@ -2206,6 +2208,8 @@ func (*SessionSessionLimitsChangedData) Type() SessionEventType { type SessionResumeData struct { // Whether the session was already in use by another client at resume time AlreadyInUse *bool `json:"alreadyInUse,omitempty"` + // Auto routing preference active at resume time + AutoTier *AutoTier `json:"autoTier,omitempty"` // Updated working directory and git context at resume time Context *WorkingDirectoryContext `json:"context,omitempty"` // Context tier currently selected at resume time; null when no tier is active @@ -2808,6 +2812,8 @@ type AssistantMessageServerTools struct { type AssistantMessageToolRequest struct { // Arguments to pass to the tool, format depends on the tool Arguments any `json:"arguments,omitempty"` + // Hosted program that requested this client tool call + Caller *AssistantMessageToolRequestCaller `json:"caller,omitempty"` // Resolved intention summary describing what this specific call does IntentionSummary *string `json:"intentionSummary,omitempty"` // Name of the MCP server hosting this tool, when the tool is an MCP tool @@ -2824,6 +2830,14 @@ type AssistantMessageToolRequest struct { Type *AssistantMessageToolRequestType `json:"type,omitempty"` } +// Hosted program that requested this client tool call +type AssistantMessageToolRequestCaller struct { + // Provider-assigned identifier for the hosted caller. + CallerID string `json:"callerId"` + // Kind of hosted caller that requested the client tool call. + Type AssistantMessageToolRequestCallerType `json:"type"` +} + // Per-request cost and usage data from the CAPI copilot_usage response field type AssistantUsageCopilotUsage struct { // Itemized token usage breakdown @@ -4856,6 +4870,13 @@ const ( AgentInterruptedCancelPhasePreFirstToken AgentInterruptedCancelPhase = "pre_first_token" ) +// Hosted program caller type +type AssistantMessageToolRequestCallerType string + +const ( + AssistantMessageToolRequestCallerTypeProgram AssistantMessageToolRequestCallerType = "program" +) + // Tool call type: "function" for standard tool calls, "custom" for grammar-based tool calls. Defaults to "function" when absent. type AssistantMessageToolRequestType string diff --git a/go/zsession_events.go b/go/zsession_events.go index 38ea78087b..8731009064 100644 --- a/go/zsession_events.go +++ b/go/zsession_events.go @@ -23,6 +23,8 @@ type ( AssistantMessageServerTools = rpc.AssistantMessageServerTools AssistantMessageStartData = rpc.AssistantMessageStartData AssistantMessageToolRequest = rpc.AssistantMessageToolRequest + AssistantMessageToolRequestCaller = rpc.AssistantMessageToolRequestCaller + AssistantMessageToolRequestCallerType = rpc.AssistantMessageToolRequestCallerType AssistantMessageToolRequestType = rpc.AssistantMessageToolRequestType AssistantReasoningData = rpc.AssistantReasoningData AssistantReasoningDeltaData = rpc.AssistantReasoningDeltaData @@ -69,6 +71,7 @@ type ( AutoModeSwitchResponse = rpc.AutoModeSwitchResponse AutopilotObjectiveChangedOperation = rpc.AutopilotObjectiveChangedOperation AutopilotObjectiveChangedStatus = rpc.AutopilotObjectiveChangedStatus + AutoTier = rpc.AutoTier BinaryAssetReference = rpc.BinaryAssetReference BinaryAssetReferenceType = rpc.BinaryAssetReferenceType BinaryAssetType = rpc.BinaryAssetType @@ -424,6 +427,7 @@ const ( AgentInterruptedActivityToolCall = rpc.AgentInterruptedActivityToolCall AgentInterruptedCancelPhaseMidStream = rpc.AgentInterruptedCancelPhaseMidStream AgentInterruptedCancelPhasePreFirstToken = rpc.AgentInterruptedCancelPhasePreFirstToken + AssistantMessageToolRequestCallerTypeProgram = rpc.AssistantMessageToolRequestCallerTypeProgram AssistantMessageToolRequestTypeCustom = rpc.AssistantMessageToolRequestTypeCustom AssistantMessageToolRequestTypeFunction = rpc.AssistantMessageToolRequestTypeFunction AssistantUsageAPIEndpointChatCompletions = rpc.AssistantUsageAPIEndpointChatCompletions @@ -472,6 +476,9 @@ const ( AutopilotObjectiveChangedStatusCapReached = rpc.AutopilotObjectiveChangedStatusCapReached AutopilotObjectiveChangedStatusCompleted = rpc.AutopilotObjectiveChangedStatusCompleted AutopilotObjectiveChangedStatusPaused = rpc.AutopilotObjectiveChangedStatusPaused + AutoTierBalance = rpc.AutoTierBalance + AutoTierEfficiency = rpc.AutoTierEfficiency + AutoTierIntelligence = rpc.AutoTierIntelligence BinaryAssetReferenceTypeImage = rpc.BinaryAssetReferenceTypeImage BinaryAssetReferenceTypeResource = rpc.BinaryAssetReferenceTypeResource BinaryAssetTypeImage = rpc.BinaryAssetTypeImage diff --git a/java/pom.xml b/java/pom.xml index 3e5e1645a5..5b493fcc55 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -63,7 +63,7 @@ DO NOT EDIT MANUALLY. Updated by the update-copilot-dependency workflow. --> - ^1.0.82-0 + ^1.0.82 true diff --git a/java/scripts/codegen/package-lock.json b/java/scripts/codegen/package-lock.json index 985ae5d5db..71abcd72dc 100644 --- a/java/scripts/codegen/package-lock.json +++ b/java/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82", "json-schema": "^0.4.0", "tsx": "^4.23.12" } @@ -428,9 +428,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82-0.tgz", - "integrity": "sha512-fSZVNAzFFYaS6btYD0+cKF7SrrtOklhpkPs/cIMZY7Fgxoa6rfZrlTWXlQNgNIdwLp51XxwTfxnZO+D9+MQ5yg==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82.tgz", + "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -439,20 +439,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82-0", - "@github/copilot-darwin-x64": "1.0.82-0", - "@github/copilot-linux-arm64": "1.0.82-0", - "@github/copilot-linux-x64": "1.0.82-0", - "@github/copilot-linuxmusl-arm64": "1.0.82-0", - "@github/copilot-linuxmusl-x64": "1.0.82-0", - "@github/copilot-win32-arm64": "1.0.82-0", - "@github/copilot-win32-x64": "1.0.82-0" + "@github/copilot-darwin-arm64": "1.0.82", + "@github/copilot-darwin-x64": "1.0.82", + "@github/copilot-linux-arm64": "1.0.82", + "@github/copilot-linux-x64": "1.0.82", + "@github/copilot-linuxmusl-arm64": "1.0.82", + "@github/copilot-linuxmusl-x64": "1.0.82", + "@github/copilot-win32-arm64": "1.0.82", + "@github/copilot-win32-x64": "1.0.82" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82-0.tgz", - "integrity": "sha512-TzBYfyvxcw3z9Mu7U8TsFo/Nq7m5XS6ahT71aPL+gx/YId0kmenI27b9daXsK6LA1D0gsFGwNBDKINddqntt1g==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82.tgz", + "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82-0.tgz", - "integrity": "sha512-Lm/U5Q8kN8yEeBTWKTfIxXAgXaT6zqdBzAAO7lA4DWHZ92AEeZZiVTs6jEWsQ2aWB2uMs6zbn0vi6t+/jIqCGw==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82.tgz", + "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", "cpu": [ "x64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82-0.tgz", - "integrity": "sha512-YERVMC1Q4p6l6KQHL5rVOI52rWbvgp9IwyzUBaVSGrfFuqu5BEvZ9bgHPsxTYi3Npkt5KVOXEyPMU5rAY09qQQ==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82.tgz", + "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82-0.tgz", - "integrity": "sha512-z2hxMVjqt4+xDRFTZv3/0K3X+aqcJhd6zPO2JxCpOVTh5CNZFaWk+XIa2iXAPWxFqdKJsQ4muXMl3zInaAOkRw==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82.tgz", + "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82-0.tgz", - "integrity": "sha512-EcUCv2PKhBzCCvpTaS511VYTDWyhudyIRPvBpc9gFNO3hjlgiNDusf4k9vP6+E3/lHenwGYzMsyRHcYIOy4vcQ==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82.tgz", + "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82-0.tgz", - "integrity": "sha512-1fKVjUiZ1tdb0/d/re90EpFGXhlIPfjENp2Wo/2Kj592dWO3+IwM2qV/AOdMQ4pacW5iYHII7nibx1/EYq3LGQ==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82.tgz", + "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82-0.tgz", - "integrity": "sha512-H341wuxQHhwe/yLmORHzwC3DGzFZgGzh+TpwfyK2zjeYVbwDZ3Bax8M+9CzIED9jIBdEfAmfGzxKUHFugpXTtQ==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82.tgz", + "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", "cpu": [ "arm64" ], @@ -562,9 +562,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82-0.tgz", - "integrity": "sha512-f1ba3gG8NaoYWFHtHaHcLN4It7mclkWdCOXvwFPqPEwqCEIx/+Zh6VHiOeIcNWRS0elRP6QYDCKaTDy1TW27uQ==", + "version": "1.0.82", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82.tgz", + "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", "cpu": [ "x64" ], diff --git a/java/scripts/codegen/package.json b/java/scripts/codegen/package.json index f5d6bf66da..1b33a27904 100644 --- a/java/scripts/codegen/package.json +++ b/java/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82", "json-schema": "^0.4.0", "tsx": "^4.23.12" } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequest.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequest.java index 2013734012..bcc7c8206d 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequest.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequest.java @@ -36,6 +36,8 @@ public record AssistantMessageToolRequest( /** Original tool name on the MCP server, when the tool is an MCP tool */ @JsonProperty("mcpToolName") String mcpToolName, /** Resolved intention summary describing what this specific call does */ - @JsonProperty("intentionSummary") String intentionSummary + @JsonProperty("intentionSummary") String intentionSummary, + /** Hosted program that requested this client tool call */ + @JsonProperty("caller") AssistantMessageToolRequestCaller caller ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCaller.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCaller.java new file mode 100644 index 0000000000..cb3d05a649 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCaller.java @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Hosted program that requested this client tool call + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record AssistantMessageToolRequestCaller( + /** Kind of hosted caller that requested the client tool call. */ + @JsonProperty("type") AssistantMessageToolRequestCallerType type, + /** Provider-assigned identifier for the hosted caller. */ + @JsonProperty("callerId") String callerId +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCallerType.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCallerType.java new file mode 100644 index 0000000000..05be5068d9 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageToolRequestCallerType.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import javax.annotation.processing.Generated; + +/** + * Hosted program caller type + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum AssistantMessageToolRequestCallerType { + /** The {@code program} variant. */ + PROGRAM("program"); + + private final String value; + AssistantMessageToolRequestCallerType(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static AssistantMessageToolRequestCallerType fromValue(String value) { + for (AssistantMessageToolRequestCallerType v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown AssistantMessageToolRequestCallerType value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AutoTier.java b/java/sdk/src/generated/java/com/github/copilot/generated/AutoTier.java new file mode 100644 index 0000000000..254543160a --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AutoTier.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import javax.annotation.processing.Generated; + +/** + * Routing preference used when the session model is `auto`. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum AutoTier { + /** The {@code efficiency} variant. */ + EFFICIENCY("efficiency"), + /** The {@code balance} variant. */ + BALANCE("balance"), + /** The {@code intelligence} variant. */ + INTELLIGENCE("intelligence"); + + private final String value; + AutoTier(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static AutoTier fromValue(String value) { + for (AutoTier v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown AutoTier value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java index a3f39d7696..de54f8a3ce 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionResumeEvent.java @@ -51,6 +51,8 @@ public record SessionResumeEventData( @JsonProperty("verbosity") Verbosity verbosity, /** Context tier currently selected at resume time; null when no tier is active */ @JsonProperty("contextTier") ContextTier contextTier, + /** Auto routing preference active at resume time */ + @JsonProperty("autoTier") AutoTier autoTier, /** Session limits currently configured at resume time; null when no limits are active */ @JsonProperty("sessionLimits") SessionLimitsConfig sessionLimits, /** Updated working directory and git context at resume time */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java index bf8b4e91cf..b977ae036f 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionStartEvent.java @@ -55,6 +55,8 @@ public record SessionStartEventData( @JsonProperty("verbosity") Verbosity verbosity, /** Context tier selected at session creation time for models with tiered context pricing; null when no tier is selected (e.g., non-tiered model) */ @JsonProperty("contextTier") ContextTier contextTier, + /** Auto routing preference selected at session creation time */ + @JsonProperty("autoTier") AutoTier autoTier, /** Session limits configured at session creation time, if any */ @JsonProperty("sessionLimits") SessionLimitsConfig sessionLimits, /** Working directory and git context at session start */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/AutoTier.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/AutoTier.java new file mode 100644 index 0000000000..a4433e1ea9 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/AutoTier.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import javax.annotation.processing.Generated; + +/** + * Routing preference used when the session model is `auto`. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum AutoTier { + /** The {@code efficiency} variant. */ + EFFICIENCY("efficiency"), + /** The {@code balance} variant. */ + BALANCE("balance"), + /** The {@code intelligence} variant. */ + INTELLIGENCE("intelligence"); + + private final String value; + AutoTier(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static AutoTier fromValue(String value) { + for (AutoTier v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown AutoTier value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java index 27fd29128d..e77117b2cb 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CapiSessionOptions.java @@ -21,6 +21,8 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record CapiSessionOptions( + /** Routing preference used when the session model is `auto`. The runtime persists the preference across cold resume. When omitted, the default routing behavior is used. Resuming an already-resident session cannot change its preference. */ + @JsonProperty("autoTier") AutoTier autoTier, /** Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. */ @JsonProperty("enableWebSocketResponses") Boolean enableWebSocketResponses ) { diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionCommandsEnqueueParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionCommandsEnqueueParams.java index d7725bc9cf..073038d2fc 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionCommandsEnqueueParams.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionCommandsEnqueueParams.java @@ -27,6 +27,8 @@ public record SessionCommandsEnqueueParams( /** Target session identifier */ @JsonProperty("sessionId") String sessionId, /** Slash-prefixed command string to enqueue, e.g. '/compact' or '/model gpt-4'. Queued FIFO with any in-flight items; if the session is idle, processing kicks off immediately. */ - @JsonProperty("command") String command + @JsonProperty("command") String command, + /** Optional user-facing text for the queue row. The command string is shown when omitted. */ + @JsonProperty("displayText") String displayText ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java index 05cfd396d4..b9bd6b8226 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java @@ -29,6 +29,8 @@ public final class SessionRpc { private final RpcCaller caller; private final String sessionId; + /** API methods for the {@code sandbox} namespace. */ + public final SessionSandboxApi sandbox; /** API methods for the {@code gitHubAuth} namespace. */ public final SessionGitHubAuthApi gitHubAuth; /** API methods for the {@code debug} namespace. */ @@ -115,6 +117,7 @@ public final class SessionRpc { public SessionRpc(RpcCaller caller, String sessionId) { this.caller = caller; this.sessionId = sessionId; + this.sandbox = new SessionSandboxApi(caller, sessionId); this.gitHubAuth = new SessionGitHubAuthApi(caller, sessionId); this.debug = new SessionDebugApi(caller, sessionId); this.canvas = new SessionCanvasApi(caller, sessionId); diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java new file mode 100644 index 0000000000..55efb9da78 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.github.copilot.CopilotExperimental; +import java.util.concurrent.CompletableFuture; +import javax.annotation.processing.Generated; + +/** + * API methods for the {@code sandbox} namespace. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class SessionSandboxApi { + + private final RpcCaller caller; + private final String sessionId; + + /** @param caller the RPC transport function */ + SessionSandboxApi(RpcCaller caller, String sessionId) { + this.caller = caller; + this.sessionId = sessionId; + } + + /** + * Identifies the target session. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture getEnforcementStatus() { + return caller.invoke("session.sandbox.getEnforcementStatus", java.util.Map.of("sessionId", this.sessionId), SessionSandboxGetEnforcementStatusResult.class); + } + +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusParams.java new file mode 100644 index 0000000000..ec92981e04 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusParams.java @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * Identifies the target session. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionSandboxGetEnforcementStatusParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusResult.java new file mode 100644 index 0000000000..c1a0e77a1a --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGetEnforcementStatusResult.java @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * Managed sandbox enforcement state for a session. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionSandboxGetEnforcementStatusResult( + /** Whether the effective managed policy requires an available sandbox backend. */ + @JsonProperty("required") Boolean required, + /** Whether an enforcement failure has permanently blocked the session. */ + @JsonProperty("blocked") Boolean blocked, + /** The first sandbox enforcement failure that blocked the session. */ + @JsonProperty("reason") String reason +) { +} diff --git a/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java b/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java index 529c42f2bb..b75e710720 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java @@ -213,7 +213,7 @@ void testHandlerReceivesCorrectEventData() { SessionStartEvent startEvent = createSessionStartEvent(); startEvent.setData(new SessionStartEvent.SessionStartEventData("my-session-123", null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null)); + null, null, null, null, null, null, null, null, null, null, null)); dispatchEvent(startEvent); AssistantMessageEvent msgEvent = createAssistantMessageEvent("Test content"); @@ -890,7 +890,7 @@ private SessionStartEvent createSessionStartEvent() { private SessionStartEvent createSessionStartEvent(String sessionId) { var event = new SessionStartEvent(); var data = new SessionStartEvent.SessionStartEventData(sessionId, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null); event.setData(data); return event; } diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3b8e6cb861..6cefab6031 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -658,8 +658,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82-0", - "integrity": "sha512-fSZVNAzFFYaS6btYD0+cKF7SrrtOklhpkPs/cIMZY7Fgxoa6rfZrlTWXlQNgNIdwLp51XxwTfxnZO+D9+MQ5yg==", + "version": "1.0.82", + "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -668,19 +668,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82-0", - "@github/copilot-darwin-x64": "1.0.82-0", - "@github/copilot-linux-arm64": "1.0.82-0", - "@github/copilot-linux-x64": "1.0.82-0", - "@github/copilot-linuxmusl-arm64": "1.0.82-0", - "@github/copilot-linuxmusl-x64": "1.0.82-0", - "@github/copilot-win32-arm64": "1.0.82-0", - "@github/copilot-win32-x64": "1.0.82-0" + "@github/copilot-darwin-arm64": "1.0.82", + "@github/copilot-darwin-x64": "1.0.82", + "@github/copilot-linux-arm64": "1.0.82", + "@github/copilot-linux-x64": "1.0.82", + "@github/copilot-linuxmusl-arm64": "1.0.82", + "@github/copilot-linuxmusl-x64": "1.0.82", + "@github/copilot-win32-arm64": "1.0.82", + "@github/copilot-win32-x64": "1.0.82" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-TzBYfyvxcw3z9Mu7U8TsFo/Nq7m5XS6ahT71aPL+gx/YId0kmenI27b9daXsK6LA1D0gsFGwNBDKINddqntt1g==", + "version": "1.0.82", + "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", "cpu": [ "arm64" ], @@ -694,8 +694,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82-0", - "integrity": "sha512-Lm/U5Q8kN8yEeBTWKTfIxXAgXaT6zqdBzAAO7lA4DWHZ92AEeZZiVTs6jEWsQ2aWB2uMs6zbn0vi6t+/jIqCGw==", + "version": "1.0.82", + "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", "cpu": [ "x64" ], @@ -709,8 +709,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-YERVMC1Q4p6l6KQHL5rVOI52rWbvgp9IwyzUBaVSGrfFuqu5BEvZ9bgHPsxTYi3Npkt5KVOXEyPMU5rAY09qQQ==", + "version": "1.0.82", + "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", "cpu": [ "arm64" ], @@ -724,8 +724,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82-0", - "integrity": "sha512-z2hxMVjqt4+xDRFTZv3/0K3X+aqcJhd6zPO2JxCpOVTh5CNZFaWk+XIa2iXAPWxFqdKJsQ4muXMl3zInaAOkRw==", + "version": "1.0.82", + "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", "cpu": [ "x64" ], @@ -739,8 +739,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-EcUCv2PKhBzCCvpTaS511VYTDWyhudyIRPvBpc9gFNO3hjlgiNDusf4k9vP6+E3/lHenwGYzMsyRHcYIOy4vcQ==", + "version": "1.0.82", + "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", "cpu": [ "arm64" ], @@ -754,8 +754,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82-0", - "integrity": "sha512-1fKVjUiZ1tdb0/d/re90EpFGXhlIPfjENp2Wo/2Kj592dWO3+IwM2qV/AOdMQ4pacW5iYHII7nibx1/EYq3LGQ==", + "version": "1.0.82", + "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", "cpu": [ "x64" ], @@ -769,8 +769,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-H341wuxQHhwe/yLmORHzwC3DGzFZgGzh+TpwfyK2zjeYVbwDZ3Bax8M+9CzIED9jIBdEfAmfGzxKUHFugpXTtQ==", + "version": "1.0.82", + "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", "cpu": [ "arm64" ], @@ -784,8 +784,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82-0", - "integrity": "sha512-f1ba3gG8NaoYWFHtHaHcLN4It7mclkWdCOXvwFPqPEwqCEIx/+Zh6VHiOeIcNWRS0elRP6QYDCKaTDy1TW27uQ==", + "version": "1.0.82", + "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 89863520e0..ff54fb3682 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index ad675a88c7..16a17c7d21 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index db0ea63dcc..024dd3a5bb 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -5,7 +5,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; -import type { AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; +import type { AbortReason, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; /** A value that can be represented losslessly on the SDK JSON wire. */ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -5487,6 +5487,7 @@ export interface CanvasProviderUnregisterRequest { */ /** @experimental */ export interface CapiSessionOptions { + autoTier?: AutoTier; /** * Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. */ @@ -6859,6 +6860,10 @@ export interface EnqueueCommandParams { * Slash-prefixed command string to enqueue, e.g. '/compact' or '/model gpt-4'. Queued FIFO with any in-flight items; if the session is idle, processing kicks off immediately. */ command: string; + /** + * Optional user-facing text for the queue row. The command string is shown when omitted. + */ + displayText?: string | null; } /** * Indicates whether the command was accepted into the local execution queue. @@ -16588,6 +16593,27 @@ export interface SandboxConfigAuth { */ gh?: boolean; } +/** + * Managed sandbox enforcement state for a session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SandboxEnforcementStatus". + */ +/** @experimental */ +export interface SandboxEnforcementStatus { + /** + * Whether the effective managed policy requires an available sandbox backend. + */ + required: boolean; + /** + * Whether an enforcement failure has permanently blocked the session. + */ + blocked: boolean; + /** + * The first sandbox enforcement failure that blocked the session. + */ + reason?: string; +} /** * Register an absolute-time scheduled prompt. * @@ -23625,6 +23651,16 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin */ sendMessages: async (params: SendMessagesRequest): Promise => connection.sendRequest("session.sendMessages", { sessionId, ...params }), + /** @experimental */ + sandbox: { + /** + * Returns whether managed policy requires sandbox enforcement and whether an enforcement failure has permanently blocked the session. + * + * @returns Managed sandbox enforcement state for a session. + */ + getEnforcementStatus: async (): Promise => + connection.sendRequest("session.sandbox.getEnforcementStatus", { sessionId }), + }, /** * Aborts the current agent turn. * diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 9075379982..cb845443aa 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -135,6 +135,16 @@ export type SessionEvent = | CanvasRemovedEvent | ExtensionsAttachmentsPushedEvent | McpAppToolCallCompleteEvent; +/** + * Routing preference used when the session model is `auto`. + */ +export type AutoTier = + /** Optimize for efficiency. */ + | "efficiency" + /** Balance efficiency and intelligence. */ + | "balance" + /** Optimize for intelligence. */ + | "intelligence"; /** * Hosting platform type of the repository (github or ado) */ @@ -469,6 +479,10 @@ export type CitationProvider = */ /** @experimental */ export type CitationLocation = CitationLocationChar | CitationLocationPage | CitationLocationBlock; +/** + * Hosted program caller type + */ +export type AssistantMessageToolRequestCallerType = "program"; /** * API endpoint used for this model call, matching CAPI supported_endpoints vocabulary */ @@ -1106,6 +1120,7 @@ export interface StartData { * Whether the session was already in use by another client at start time */ alreadyInUse?: boolean; + autoTier?: AutoTier; context?: WorkingDirectoryContext; /** * Context tier selected at session creation time for models with tiered context pricing; null when no tier is selected (e.g., non-tiered model) @@ -1258,6 +1273,7 @@ export interface ResumeData { * Whether the session was already in use by another client at resume time */ alreadyInUse?: boolean; + autoTier?: AutoTier; context?: WorkingDirectoryContext; /** * Context tier currently selected at resume time; null when no tier is active @@ -4843,6 +4859,7 @@ export interface AssistantMessageToolRequest { * Arguments to pass to the tool, format depends on the tool */ arguments?: JsonValue; + caller?: AssistantMessageToolRequestCaller; /** * Resolved intention summary describing what this specific call does */ @@ -4869,6 +4886,16 @@ export interface AssistantMessageToolRequest { toolTitle?: string; type?: AssistantMessageToolRequestType; } +/** + * Hosted program that requested this client tool call + */ +export interface AssistantMessageToolRequestCaller { + /** + * Provider-assigned identifier for the hosted caller. + */ + callerId: string; + type: AssistantMessageToolRequestCallerType; +} /** * Session event "assistant.message_start". Streaming assistant message start metadata */ diff --git a/nodejs/test/session-event-codegen.test.ts b/nodejs/test/session-event-codegen.test.ts index c84d9bc082..2dad8f4f18 100644 --- a/nodejs/test/session-event-codegen.test.ts +++ b/nodejs/test/session-event-codegen.test.ts @@ -3,10 +3,37 @@ import { describe, expect, it } from "vitest"; import { generateSessionEventsCode as generateCSharpSessionEventsCode } from "../../scripts/codegen/csharp.ts"; import { generateGoSessionEventsCode } from "../../scripts/codegen/go.ts"; -import { generatePythonSessionEventsCode } from "../../scripts/codegen/python.ts"; +import { + generatePythonSessionEventsCode, + postProcessExternalRefsForPython, +} from "../../scripts/codegen/python.ts"; import { generateSessionEventsCode as generateRustSessionEventsCode } from "../../scripts/codegen/rust.ts"; describe("session event codegen", () => { + it("replaces external reference placeholders regardless of acronym casing", () => { + const code = `@dataclass +class ExternalRefMCPOauthHTTPResponse: + external_ref_marker_external_ref_mcp_oauth_http_response: str + + @staticmethod + def from_dict(obj: Any) -> 'ExternalRefMCPOauthHTTPResponse': + value = obj.get("__externalRefMarker___ExternalRef_McpOauthHttpResponse") + return ExternalRefMCPOauthHTTPResponse(value) + +@dataclass +class ProbeResult: + response: ExternalRefMCPOauthHTTPResponse +`; + + const processed = postProcessExternalRefsForPython( + code, + new Map([["__ExternalRef_McpOauthHttpResponse", "McpOauthHttpResponse"]]) + ); + + expect(processed).toContain("response: McpOauthHttpResponse"); + expect(processed).not.toContain("class ExternalRefMCPOauthHTTPResponse"); + }); + it("maps special schema formats to the expected Python types", () => { const schema: JSONSchema7 = { definitions: { diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 1d59a55f4a..84f3718b16 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -6,7 +6,7 @@ from typing import ClassVar, TYPE_CHECKING -from .session_events import AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, Verbosity +from .session_events import AbortReason, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, Verbosity if TYPE_CHECKING: from .._jsonrpc import JsonRpcClient @@ -1051,6 +1051,11 @@ def to_dict(self) -> dict: class CapiSessionOptions: """Options scoped to the built-in CAPI (Copilot API) provider.""" + auto_tier: AutoTier | None = None + """Routing preference used when the session model is `auto`. The runtime persists the + preference across cold resume. When omitted, the default routing behavior is used. + Resuming an already-resident session cannot change its preference. + """ enable_web_socket_responses: bool | None = None """Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses @@ -1062,11 +1067,14 @@ class CapiSessionOptions: @staticmethod def from_dict(obj: Any) -> 'CapiSessionOptions': assert isinstance(obj, dict) + auto_tier = from_union([AutoTier, from_none], obj.get("autoTier")) enable_web_socket_responses = from_union([from_bool, from_none], obj.get("enableWebSocketResponses")) - return CapiSessionOptions(enable_web_socket_responses) + return CapiSessionOptions(auto_tier, enable_web_socket_responses) def to_dict(self) -> dict: result: dict = {} + if self.auto_tier is not None: + result["autoTier"] = from_union([lambda x: to_enum(AutoTier, x), from_none], self.auto_tier) if self.enable_web_socket_responses is not None: result["enableWebSocketResponses"] = from_union([from_bool, from_none], self.enable_web_socket_responses) return result @@ -2502,16 +2510,21 @@ class EnqueueCommandParams: """Slash-prefixed command string to enqueue, e.g. '/compact' or '/model gpt-4'. Queued FIFO with any in-flight items; if the session is idle, processing kicks off immediately. """ + display_text: str | None = None + """Optional user-facing text for the queue row. The command string is shown when omitted.""" @staticmethod def from_dict(obj: Any) -> 'EnqueueCommandParams': assert isinstance(obj, dict) command = from_str(obj.get("command")) - return EnqueueCommandParams(command) + display_text = from_union([from_none, from_str], obj.get("displayText")) + return EnqueueCommandParams(command, display_text) def to_dict(self) -> dict: result: dict = {} result["command"] = from_str(self.command) + if self.display_text is not None: + result["displayText"] = from_union([from_none, from_str], self.display_text) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -5576,28 +5589,6 @@ def to_dict(self) -> dict: result["serverName"] = from_str(self.server_name) return result -@dataclass -class ExternalRefMCPOauthHTTPResponse: - """HTTP response returned by the server. - - HTTP 401 or 403 response returned by the server. - - HTTP response returned by the server, when the probe reached the server and captured the - complete response. - """ - external_ref_marker_external_ref_mcp_oauth_http_response: str - - @staticmethod - def from_dict(obj: Any) -> 'ExternalRefMCPOauthHTTPResponse': - assert isinstance(obj, dict) - external_ref_marker_external_ref_mcp_oauth_http_response = from_str(obj.get("__externalRefMarker___ExternalRef_McpOauthHttpResponse")) - return ExternalRefMCPOauthHTTPResponse(external_ref_marker_external_ref_mcp_oauth_http_response) - - def to_dict(self) -> dict: - result: dict = {} - result["__externalRefMarker___ExternalRef_McpOauthHttpResponse"] = from_str(self.external_ref_marker_external_ref_mcp_oauth_http_response) - return result - class Status(Enum): AUTHENTICATED = "authenticated" FAILED = "failed" @@ -9664,6 +9655,36 @@ class _SandboxConfigSource(Enum): USER_DISABLED = "user_disabled" USER_ENABLED = "user_enabled" +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SandboxEnforcementStatus: + """Managed sandbox enforcement state for a session.""" + + blocked: bool + """Whether an enforcement failure has permanently blocked the session.""" + + required: bool + """Whether the effective managed policy requires an available sandbox backend.""" + + reason: str | None = None + """The first sandbox enforcement failure that blocked the session.""" + + @staticmethod + def from_dict(obj: Any) -> 'SandboxEnforcementStatus': + assert isinstance(obj, dict) + blocked = from_bool(obj.get("blocked")) + required = from_bool(obj.get("required")) + reason = from_union([from_str, from_none], obj.get("reason")) + return SandboxEnforcementStatus(blocked, required, reason) + + def to_dict(self) -> dict: + result: dict = {} + result["blocked"] = from_bool(self.blocked) + result["required"] = from_bool(self.required) + if self.reason is not None: + result["reason"] = from_union([from_str, from_none], self.reason) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ScheduleAddAtRequest: @@ -32942,7 +32963,7 @@ class MCPOauthProbeResult: status: Status """Probe outcome variant discriminator.""" - http_response: ExternalRefMCPOauthHTTPResponse | None = None + http_response: McpOauthHttpResponse | None = None """HTTP response returned by the server. HTTP 401 or 403 response returned by the server. @@ -32963,7 +32984,7 @@ class MCPOauthProbeResult: def from_dict(obj: Any) -> 'MCPOauthProbeResult': assert isinstance(obj, dict) status = Status(obj.get("status")) - http_response = from_union([ExternalRefMCPOauthHTTPResponse.from_dict, from_none], obj.get("httpResponse")) + http_response = from_union([McpOauthHttpResponse.from_dict, from_none], obj.get("httpResponse")) reason = from_union([MCPOauthProbeNeedsAuthReason, from_none], obj.get("reason")) www_authenticate_params = from_union([McpOauthWWWAuthenticateParams.from_dict, from_none], obj.get("wwwAuthenticateParams")) error = from_union([from_str, from_none], obj.get("error")) @@ -32973,7 +32994,7 @@ def to_dict(self) -> dict: result: dict = {} result["status"] = to_enum(Status, self.status) if self.http_response is not None: - result["httpResponse"] = from_union([lambda x: to_class(ExternalRefMCPOauthHTTPResponse, x), from_none], self.http_response) + result["httpResponse"] = from_union([lambda x: to_class(McpOauthHttpResponse, x), from_none], self.http_response) if self.reason is not None: result["reason"] = from_union([lambda x: to_enum(MCPOauthProbeNeedsAuthReason, x), from_none], self.reason) if self.www_authenticate_params is not None: @@ -35849,6 +35870,7 @@ class RPC: sandbox_config_user_policy_network: SandboxConfigUserPolicyNetwork sandbox_config_user_policy_network_proxy: SandboxConfigUserPolicyNetworkProxy sandbox_config_user_policy_seatbelt: SandboxConfigUserPolicySeatbelt + sandbox_enforcement_status: SandboxEnforcementStatus schedule_add_at_request: ScheduleAddAtRequest schedule_add_cron_request: ScheduleAddCronRequest schedule_add_request: ScheduleAddRequest @@ -37032,6 +37054,7 @@ def from_dict(obj: Any) -> 'RPC': sandbox_config_user_policy_network = SandboxConfigUserPolicyNetwork.from_dict(obj.get("SandboxConfigUserPolicyNetwork")) sandbox_config_user_policy_network_proxy = SandboxConfigUserPolicyNetworkProxy.from_dict(obj.get("SandboxConfigUserPolicyNetworkProxy")) sandbox_config_user_policy_seatbelt = SandboxConfigUserPolicySeatbelt.from_dict(obj.get("SandboxConfigUserPolicySeatbelt")) + sandbox_enforcement_status = SandboxEnforcementStatus.from_dict(obj.get("SandboxEnforcementStatus")) schedule_add_at_request = ScheduleAddAtRequest.from_dict(obj.get("ScheduleAddAtRequest")) schedule_add_cron_request = ScheduleAddCronRequest.from_dict(obj.get("ScheduleAddCronRequest")) schedule_add_request = ScheduleAddRequest.from_dict(obj.get("ScheduleAddRequest")) @@ -37410,7 +37433,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, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_list_request, 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, agent_set_prompt_request, agents_get_discovery_paths_request, api_key_auth_info, auth_identity, auth_info, auth_info_type, auth_validation_error, auth_validation_errors, built_in_model_catalog, built_in_model_catalog_entry, builtin_tool_descriptor, builtin_tool_format, builtin_tool_format_type, builtin_tool_input_schema, builtin_tool_input_schema_type, builtin_tool_safe_for_telemetry, builtin_tool_safe_telemetry_fields, 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_provider_register_request, canvas_provider_unregister_request, canvas_session_context, capi_session_options, card_digest, card_digest_algorithm, card_digest_value, catalog_ai_skill_candidate, catalog_ai_skill_candidate_provenance, catalog_authentication_required_error, catalog_authentication_required_reason, catalog_candidate, catalog_candidate_kind, catalog_candidate_source, catalog_candidate_source_embedded, catalog_candidate_source_url, catalog_capability, catalog_capability_id, catalog_client_contract, catalog_contract_violation_error, catalog_contract_violation_reason, catalog_handle_rejected_error, catalog_handle_rejection_reason, catalog_handle_type, catalog_invalid_request_error, catalog_invalid_request_field, catalog_malformed_card_error, catalog_malformed_card_reason, catalog_mcp_server_candidate, catalog_mcp_server_candidate_provenance, catalog_mcp_server_installability, catalog_media_type, catalog_negotiated_contract, catalog_negotiation_refused_error, catalog_negotiation_refused_reason, catalog_network_failure_error, catalog_network_failure_reason, catalog_not_installable_error, catalog_not_installable_reason, catalog_policy_rejected_error, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_unavailable_error, catalog_unavailable_reason, catalog_unavailable_transport_error, catalog_unavailable_transport_reason, catalog_unsafe_retrieval_error, catalog_unsafe_retrieval_reason, catalog_unsupported_kind_error, command_list, commands_finalize_invocation_effect_request, commands_finalize_invocation_effect_result, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invocation_effect_outcome, commands_invocation_origin, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connect_client_info, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_exclusion_check_paths_request, content_exclusion_check_paths_result, content_exclusion_path_check, content_filter_mode, context_heaviest_message, 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, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, 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_direction, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_launch_profile, extension_launch_provider_resolve_request, extension_launch_provider_resolve_result, 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, factory_abort_request, factory_ack_result, factory_agent_options, factory_agent_request, factory_agent_result, factory_agent_summary, factory_cancel_request, factory_current_phase, factory_declared_limits, factory_durable_operation, factory_execute_request, factory_execute_result, factory_get_run_progress_request, factory_get_run_request, factory_journal_get_request, factory_journal_get_result, factory_journal_put_request, factory_list_runs_request, factory_list_runs_result, factory_log_line, factory_log_line_kind, factory_log_request, factory_phase_observation, factory_phase_status, factory_progress_line, factory_progress_page, factory_resume_request, factory_resume_result, factory_run_consumed, factory_run_detail, factory_run_failure, factory_run_failure_kind, factory_run_limits, factory_run_request, factory_run_result, factory_run_status, factory_run_summary, factory_run_terminal, factory_tool_resume_request, factory_tool_run_options, factory_tool_run_request, 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, git_hub_token_acquire_reason, git_hub_token_acquire_request, git_hub_token_acquire_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_clear_context_request, history_clear_context_result, history_compact_context_window, history_compact_request, history_compact_result, history_file_restore_skip_reason, history_list_rewind_points_result, history_preview_rewind_request, history_preview_rewind_result, history_rewind_change_type, history_rewind_file_preview, history_rewind_mode, history_rewind_outcome, history_rewind_point, history_rewind_request, history_rewind_result, history_rewind_unavailable_reason, history_skipped_file_restore, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, hook_invoke_request, hook_invoke_response, hook_type, 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, interrupt_main_turn_request, interrupt_main_turn_result, 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, managed_settings_read_result, 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_elicitation_form_mode, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_failed_server, 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_install_plan, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_authentication_state_changed_request, 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_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_plan_configuration_change, mcp_plan_configuration_operation, mcp_plan_enum_value_type, mcp_plan_install_planned, mcp_plan_install_request, mcp_plan_install_result, mcp_plan_install_source, mcp_plan_install_source_candidate, mcp_plan_install_source_candidate_kind, mcp_plan_install_source_card, mcp_plan_install_source_card_kind, mcp_plan_package_install_method, mcp_plan_package_transport, mcp_plan_policy_decision, mcp_plan_policy_result, mcp_plan_policy_source, mcp_plan_provenance, mcp_plan_remote_install_method, mcp_plan_remote_transport, mcp_plan_required_value, mcp_plan_required_value_enum, mcp_plan_required_value_enum_kind, mcp_plan_required_value_scalar, mcp_plan_required_value_scalar_kind, mcp_plan_resource_identity, mcp_plan_scalar_value_type, mcp_plan_scope, mcp_plan_secret_placeholder, mcp_plan_secret_reference, mcp_plan_target, mcp_plan_transport_choice, mcp_plan_transport_choice_package, mcp_plan_transport_choice_remote, mcp_plan_value_category, mcp_register_external_client_request, mcp_reload_config, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_resource, mcp_resource_annotations, mcp_resource_content, mcp_resource_icon, mcp_resources_list_request, mcp_resources_list_result, mcp_resources_list_templates_request, mcp_resources_list_templates_result, mcp_resources_read_request, mcp_resources_read_result, mcp_resource_template, mcp_restart_server_request, mcp_safe_for_telemetry, mcp_safe_for_telemetry_fields, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_serializable_server_config, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_card_embedded, mcp_server_card_embedded_kind, mcp_server_card_media_type, mcp_server_card_reference, mcp_server_card_url, mcp_server_card_url_kind, 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_memory, mcp_server_config_memory_type, mcp_server_config_stdio, mcp_server_config_stdio_type, 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_task_metadata, mcp_tools, mcp_tool_ui, mcp_tool_ui_visibility, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, 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_apply_startup_overlay_request, model_billing, model_billing_promo, 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_message, model_picker_category, model_picker_persistence_request, model_picker_price_category, model_picker_settings_context, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_confirmation, model_switch_to_request, model_switch_to_result, model_warning_text, mode_set_request, mode_set_result, move_mcp_loading_to_background_result, 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_env_access, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_factory, 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_env_access, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_factory, 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_context, 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_outcome, permission_decision_reject, permission_decision_request, permission_decision_source, permission_decision_surface, 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_mode_source, 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_response_capability, 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_mode_request, permissions_get_mode_result, 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_env_access, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_factory, 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_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_mode_request, permissions_set_mode_result, 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_builtin_set_request, 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, protocol_external_tool_defer, protocol_external_tool_definition, 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, queue_begin_deferred_idle_drain_request, queue_begin_deferred_idle_drain_result, queue_consume_system_notifications_request, queued_command_handled, queued_command_not_handled, queued_command_result, queue_defer_session_idle_request, queue_duplicate_at_request, queue_duplicate_at_result, queue_enqueue_resume_pending_result, queue_finish_deferred_idle_drain_request, queue_finish_deferred_idle_drain_result, queue_has_pending_result, queue_insert_at_request, queue_insert_at_result, queue_insert_message, queue_move_item_request, queue_move_item_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_at_request, queue_remove_at_result, queue_remove_most_recent_result, queue_send_now_request, queue_send_now_result, queue_set_drain_paused_request, queue_snapshot_result, queue_update_text_request, queue_update_text_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_host_status, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, run_options, sandbox_config, sandbox_config_auth, sandbox_config_source, 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_network_proxy, sandbox_config_user_policy_seatbelt, schedule_add_at_request, schedule_add_cron_request, schedule_add_request, schedule_add_result, schedule_add_self_paced_request, schedule_entry, schedule_has_self_paced_result, schedule_list, schedule_rearm_self_paced_request, 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_message_item, send_messages_request, send_messages_result, send_mode, send_request, send_result, send_system_notification_request, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_agent_list_request, session_auth_login_request, session_auth_logout_user_request, session_auth_status, session_auth_switch_request, session_bulk_delete_result, session_cancel_all_background_agents_result, session_capability, session_commands_list_request, session_completion_item, 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_sqlite_transaction_error, session_fs_sqlite_transaction_error_class, session_fs_sqlite_transaction_request, session_fs_sqlite_transaction_result, session_fs_sqlite_transaction_statement, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_git_hub_auth_get_all_auth_available_result, session_git_hub_auth_logout_result, session_git_hub_auth_logout_user_result, session_history_compact_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_limit_prediction_baseline_data, session_limit_prediction_client_type, session_limit_prediction_details, session_limit_prediction_predict_request, session_limit_prediction_request, session_limit_prediction_result, session_limit_prediction_source, session_limit_prediction_tier, session_limit_prediction_tier_option, session_limit_prediction_unavailable_reason, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_managed_permissions, session_managed_settings, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_model_list_request, session_model_price_category, 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_plugins_reload_request, session_provider_get_endpoint_request, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_delete_request, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, 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_metadata_request, sessions_get_metadata_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_non_empty_session_ids_request, sessions_list_non_empty_session_ids_result, 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_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, settable_auth_info, settable_token_auth_info, shell_cancel_user_requested_request, shell_credentials, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_init_profile, shell_init_script, shell_init_script_shell, shell_kill_request, shell_kill_result, shell_kill_signal, shell_options, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_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_add_timeline_entry_result, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_model_picker_dialog, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_set_model_result, slash_command_set_plan_model_result, slash_command_show_dialog_result, slash_command_text_result, slash_command_timeline_entry, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_complete_data, task_completion_decision, 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, token_provider_auth_info, tool, tool_list, tool_result, tool_result_expanded, tool_result_new_message, tool_result_type, tools_execute_request, tools_get_builtin_descriptors_request, tools_get_builtin_descriptors_result, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_set_request, tools_set_result, tools_shell_descriptor_config, tools_task_complete_event_data_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_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, 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_agent_metric, 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_add_summary_request, workspaces_add_summary_result, workspaces_autopilot_objective_exists_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_delete_autopilot_objective_result, workspaces_diff_request, workspaces_ensure_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_autopilot_objective_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, workspaces_truncate_summaries_request, workspace_summary_host_type, workspaces_update_metadata_request, workspaces_workspace_details_host_type, workspaces_write_autopilot_objective_request, workspaces_write_autopilot_objective_result, session_auth_info_result, session_context_attribution, 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, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_list_request, 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, agent_set_prompt_request, agents_get_discovery_paths_request, api_key_auth_info, auth_identity, auth_info, auth_info_type, auth_validation_error, auth_validation_errors, built_in_model_catalog, built_in_model_catalog_entry, builtin_tool_descriptor, builtin_tool_format, builtin_tool_format_type, builtin_tool_input_schema, builtin_tool_input_schema_type, builtin_tool_safe_for_telemetry, builtin_tool_safe_telemetry_fields, 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_provider_register_request, canvas_provider_unregister_request, canvas_session_context, capi_session_options, card_digest, card_digest_algorithm, card_digest_value, catalog_ai_skill_candidate, catalog_ai_skill_candidate_provenance, catalog_authentication_required_error, catalog_authentication_required_reason, catalog_candidate, catalog_candidate_kind, catalog_candidate_source, catalog_candidate_source_embedded, catalog_candidate_source_url, catalog_capability, catalog_capability_id, catalog_client_contract, catalog_contract_violation_error, catalog_contract_violation_reason, catalog_handle_rejected_error, catalog_handle_rejection_reason, catalog_handle_type, catalog_invalid_request_error, catalog_invalid_request_field, catalog_malformed_card_error, catalog_malformed_card_reason, catalog_mcp_server_candidate, catalog_mcp_server_candidate_provenance, catalog_mcp_server_installability, catalog_media_type, catalog_negotiated_contract, catalog_negotiation_refused_error, catalog_negotiation_refused_reason, catalog_network_failure_error, catalog_network_failure_reason, catalog_not_installable_error, catalog_not_installable_reason, catalog_policy_rejected_error, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_unavailable_error, catalog_unavailable_reason, catalog_unavailable_transport_error, catalog_unavailable_transport_reason, catalog_unsafe_retrieval_error, catalog_unsafe_retrieval_reason, catalog_unsupported_kind_error, command_list, commands_finalize_invocation_effect_request, commands_finalize_invocation_effect_result, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invocation_effect_outcome, commands_invocation_origin, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connect_client_info, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_exclusion_check_paths_request, content_exclusion_check_paths_result, content_exclusion_path_check, content_filter_mode, context_heaviest_message, 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, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, 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_direction, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_launch_profile, extension_launch_provider_resolve_request, extension_launch_provider_resolve_result, 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, factory_abort_request, factory_ack_result, factory_agent_options, factory_agent_request, factory_agent_result, factory_agent_summary, factory_cancel_request, factory_current_phase, factory_declared_limits, factory_durable_operation, factory_execute_request, factory_execute_result, factory_get_run_progress_request, factory_get_run_request, factory_journal_get_request, factory_journal_get_result, factory_journal_put_request, factory_list_runs_request, factory_list_runs_result, factory_log_line, factory_log_line_kind, factory_log_request, factory_phase_observation, factory_phase_status, factory_progress_line, factory_progress_page, factory_resume_request, factory_resume_result, factory_run_consumed, factory_run_detail, factory_run_failure, factory_run_failure_kind, factory_run_limits, factory_run_request, factory_run_result, factory_run_status, factory_run_summary, factory_run_terminal, factory_tool_resume_request, factory_tool_run_options, factory_tool_run_request, 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, git_hub_token_acquire_reason, git_hub_token_acquire_request, git_hub_token_acquire_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_clear_context_request, history_clear_context_result, history_compact_context_window, history_compact_request, history_compact_result, history_file_restore_skip_reason, history_list_rewind_points_result, history_preview_rewind_request, history_preview_rewind_result, history_rewind_change_type, history_rewind_file_preview, history_rewind_mode, history_rewind_outcome, history_rewind_point, history_rewind_request, history_rewind_result, history_rewind_unavailable_reason, history_skipped_file_restore, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, hook_invoke_request, hook_invoke_response, hook_type, 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, interrupt_main_turn_request, interrupt_main_turn_result, 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, managed_settings_read_result, 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_elicitation_form_mode, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_failed_server, 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_install_plan, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_authentication_state_changed_request, 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_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_plan_configuration_change, mcp_plan_configuration_operation, mcp_plan_enum_value_type, mcp_plan_install_planned, mcp_plan_install_request, mcp_plan_install_result, mcp_plan_install_source, mcp_plan_install_source_candidate, mcp_plan_install_source_candidate_kind, mcp_plan_install_source_card, mcp_plan_install_source_card_kind, mcp_plan_package_install_method, mcp_plan_package_transport, mcp_plan_policy_decision, mcp_plan_policy_result, mcp_plan_policy_source, mcp_plan_provenance, mcp_plan_remote_install_method, mcp_plan_remote_transport, mcp_plan_required_value, mcp_plan_required_value_enum, mcp_plan_required_value_enum_kind, mcp_plan_required_value_scalar, mcp_plan_required_value_scalar_kind, mcp_plan_resource_identity, mcp_plan_scalar_value_type, mcp_plan_scope, mcp_plan_secret_placeholder, mcp_plan_secret_reference, mcp_plan_target, mcp_plan_transport_choice, mcp_plan_transport_choice_package, mcp_plan_transport_choice_remote, mcp_plan_value_category, mcp_register_external_client_request, mcp_reload_config, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_resource, mcp_resource_annotations, mcp_resource_content, mcp_resource_icon, mcp_resources_list_request, mcp_resources_list_result, mcp_resources_list_templates_request, mcp_resources_list_templates_result, mcp_resources_read_request, mcp_resources_read_result, mcp_resource_template, mcp_restart_server_request, mcp_safe_for_telemetry, mcp_safe_for_telemetry_fields, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_serializable_server_config, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_card_embedded, mcp_server_card_embedded_kind, mcp_server_card_media_type, mcp_server_card_reference, mcp_server_card_url, mcp_server_card_url_kind, 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_memory, mcp_server_config_memory_type, mcp_server_config_stdio, mcp_server_config_stdio_type, 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_task_metadata, mcp_tools, mcp_tool_ui, mcp_tool_ui_visibility, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, 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_apply_startup_overlay_request, model_billing, model_billing_promo, 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_message, model_picker_category, model_picker_persistence_request, model_picker_price_category, model_picker_settings_context, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_confirmation, model_switch_to_request, model_switch_to_result, model_warning_text, mode_set_request, mode_set_result, move_mcp_loading_to_background_result, 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_env_access, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_factory, 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_env_access, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_factory, 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_context, 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_outcome, permission_decision_reject, permission_decision_request, permission_decision_source, permission_decision_surface, 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_mode_source, 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_response_capability, 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_mode_request, permissions_get_mode_result, 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_env_access, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_factory, 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_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_mode_request, permissions_set_mode_result, 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_builtin_set_request, 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, protocol_external_tool_defer, protocol_external_tool_definition, 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, queue_begin_deferred_idle_drain_request, queue_begin_deferred_idle_drain_result, queue_consume_system_notifications_request, queued_command_handled, queued_command_not_handled, queued_command_result, queue_defer_session_idle_request, queue_duplicate_at_request, queue_duplicate_at_result, queue_enqueue_resume_pending_result, queue_finish_deferred_idle_drain_request, queue_finish_deferred_idle_drain_result, queue_has_pending_result, queue_insert_at_request, queue_insert_at_result, queue_insert_message, queue_move_item_request, queue_move_item_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_at_request, queue_remove_at_result, queue_remove_most_recent_result, queue_send_now_request, queue_send_now_result, queue_set_drain_paused_request, queue_snapshot_result, queue_update_text_request, queue_update_text_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_host_status, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, run_options, sandbox_config, sandbox_config_auth, sandbox_config_source, 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_network_proxy, sandbox_config_user_policy_seatbelt, sandbox_enforcement_status, schedule_add_at_request, schedule_add_cron_request, schedule_add_request, schedule_add_result, schedule_add_self_paced_request, schedule_entry, schedule_has_self_paced_result, schedule_list, schedule_rearm_self_paced_request, 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_message_item, send_messages_request, send_messages_result, send_mode, send_request, send_result, send_system_notification_request, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_agent_list_request, session_auth_login_request, session_auth_logout_user_request, session_auth_status, session_auth_switch_request, session_bulk_delete_result, session_cancel_all_background_agents_result, session_capability, session_commands_list_request, session_completion_item, 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_sqlite_transaction_error, session_fs_sqlite_transaction_error_class, session_fs_sqlite_transaction_request, session_fs_sqlite_transaction_result, session_fs_sqlite_transaction_statement, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_git_hub_auth_get_all_auth_available_result, session_git_hub_auth_logout_result, session_git_hub_auth_logout_user_result, session_history_compact_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_limit_prediction_baseline_data, session_limit_prediction_client_type, session_limit_prediction_details, session_limit_prediction_predict_request, session_limit_prediction_request, session_limit_prediction_result, session_limit_prediction_source, session_limit_prediction_tier, session_limit_prediction_tier_option, session_limit_prediction_unavailable_reason, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_managed_permissions, session_managed_settings, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_model_list_request, session_model_price_category, 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_plugins_reload_request, session_provider_get_endpoint_request, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_delete_request, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, 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_metadata_request, sessions_get_metadata_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_non_empty_session_ids_request, sessions_list_non_empty_session_ids_result, 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_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, settable_auth_info, settable_token_auth_info, shell_cancel_user_requested_request, shell_credentials, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_init_profile, shell_init_script, shell_init_script_shell, shell_kill_request, shell_kill_result, shell_kill_signal, shell_options, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_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_add_timeline_entry_result, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_model_picker_dialog, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_set_model_result, slash_command_set_plan_model_result, slash_command_show_dialog_result, slash_command_text_result, slash_command_timeline_entry, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_complete_data, task_completion_decision, 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, token_provider_auth_info, tool, tool_list, tool_result, tool_result_expanded, tool_result_new_message, tool_result_type, tools_execute_request, tools_get_builtin_descriptors_request, tools_get_builtin_descriptors_result, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_set_request, tools_set_result, tools_shell_descriptor_config, tools_task_complete_event_data_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_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, 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_agent_metric, 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_add_summary_request, workspaces_add_summary_result, workspaces_autopilot_objective_exists_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_delete_autopilot_objective_result, workspaces_diff_request, workspaces_ensure_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_autopilot_objective_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, workspaces_truncate_summaries_request, workspace_summary_host_type, workspaces_update_metadata_request, workspaces_workspace_details_host_type, workspaces_write_autopilot_objective_request, workspaces_write_autopilot_objective_result, session_auth_info_result, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -38215,6 +38238,7 @@ def to_dict(self) -> dict: result["SandboxConfigUserPolicyNetwork"] = to_class(SandboxConfigUserPolicyNetwork, self.sandbox_config_user_policy_network) result["SandboxConfigUserPolicyNetworkProxy"] = to_class(SandboxConfigUserPolicyNetworkProxy, self.sandbox_config_user_policy_network_proxy) result["SandboxConfigUserPolicySeatbelt"] = to_class(SandboxConfigUserPolicySeatbelt, self.sandbox_config_user_policy_seatbelt) + result["SandboxEnforcementStatus"] = to_class(SandboxEnforcementStatus, self.sandbox_enforcement_status) result["ScheduleAddAtRequest"] = to_class(ScheduleAddAtRequest, self.schedule_add_at_request) result["ScheduleAddCronRequest"] = to_class(ScheduleAddCronRequest, self.schedule_add_cron_request) result["ScheduleAddRequest"] = to_class(ScheduleAddRequest, self.schedule_add_request) @@ -39663,6 +39687,17 @@ async def _connect(self, params: _ConnectRequest, *, timeout: float | None = Non return _ConnectResult.from_dict(await self._client.request("connect", params_dict, **_timeout_kwargs(timeout))) +# Experimental: this API group is experimental and may change or be removed. +class SandboxApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def get_enforcement_status(self, *, timeout: float | None = None) -> SandboxEnforcementStatus: + "Returns whether managed policy requires sandbox enforcement and whether an enforcement failure has permanently blocked the session.\n\nReturns:\n Managed sandbox enforcement state for a session." + return SandboxEnforcementStatus.from_dict(await self._client.request("session.sandbox.getEnforcementStatus", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + # Experimental: this API group is experimental and may change or be removed. class GitHubAuthApi: def __init__(self, client: "JsonRpcClient", session_id: str): @@ -41139,6 +41174,7 @@ class SessionRpc: def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id + self.sandbox = SandboxApi(client, session_id) self.git_hub_auth = GitHubAuthApi(client, session_id) self.debug = DebugApi(client, session_id) self.canvas = CanvasApi(client, session_id) @@ -42061,7 +42097,6 @@ async def handle_git_hub_token_get_token(params: dict) -> dict | None: "ExtensionsApi", "ExtensionsDisableRequest", "ExtensionsEnableRequest", - "ExternalRefMCPOauthHTTPResponse", "ExternalToolResult", "ExternalToolTextResultForLlm", "ExternalToolTextResultForLlmBinaryResultsForLlm", @@ -42766,6 +42801,7 @@ async def handle_git_hub_token_get_token(params: dict) -> dict | None: "RemoteSessionMode", "RemoteSessionRepository", "RunOptions", + "SandboxApi", "SandboxConfig", "SandboxConfigAuth", "SandboxConfigUserPolicy", @@ -42775,6 +42811,7 @@ async def handle_git_hub_token_get_token(params: dict) -> dict | None: "SandboxConfigUserPolicyNetwork", "SandboxConfigUserPolicyNetworkProxy", "SandboxConfigUserPolicySeatbelt", + "SandboxEnforcementStatus", "Saved", "ScheduleAddAtRequest", "ScheduleAddCronRequest", diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index c22f9f24fc..fd41b6c6c6 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -2450,6 +2450,7 @@ class AssistantMessageToolRequest: name: str tool_call_id: str arguments: Any = None + caller: AssistantMessageToolRequestCaller | None = None intention_summary: str | None = None mcp_server_name: str | None = None mcp_tool_name: str | None = None @@ -2462,6 +2463,7 @@ def from_dict(obj: Any) -> "AssistantMessageToolRequest": name = from_str(obj.get("name")) tool_call_id = from_str(obj.get("toolCallId")) arguments = obj.get("arguments") + caller = from_union([from_none, AssistantMessageToolRequestCaller.from_dict], obj.get("caller")) intention_summary = from_union([from_none, from_str], obj.get("intentionSummary")) mcp_server_name = from_union([from_none, from_str], obj.get("mcpServerName")) mcp_tool_name = from_union([from_none, from_str], obj.get("mcpToolName")) @@ -2471,6 +2473,7 @@ def from_dict(obj: Any) -> "AssistantMessageToolRequest": name=name, tool_call_id=tool_call_id, arguments=arguments, + caller=caller, intention_summary=intention_summary, mcp_server_name=mcp_server_name, mcp_tool_name=mcp_tool_name, @@ -2484,6 +2487,8 @@ def to_dict(self) -> dict: result["toolCallId"] = from_str(self.tool_call_id) if self.arguments is not None: result["arguments"] = self.arguments + if self.caller is not None: + result["caller"] = from_union([from_none, lambda x: to_class(AssistantMessageToolRequestCaller, x)], self.caller) if self.intention_summary is not None: result["intentionSummary"] = from_union([from_none, from_str], self.intention_summary) if self.mcp_server_name is not None: @@ -2497,6 +2502,29 @@ def to_dict(self) -> dict: return result +@dataclass +class AssistantMessageToolRequestCaller: + "Hosted program that requested this client tool call" + caller_id: str + type: AssistantMessageToolRequestCallerType + + @staticmethod + def from_dict(obj: Any) -> "AssistantMessageToolRequestCaller": + assert isinstance(obj, dict) + caller_id = from_str(obj.get("callerId")) + type = parse_enum(AssistantMessageToolRequestCallerType, obj.get("type")) + return AssistantMessageToolRequestCaller( + caller_id=caller_id, + type=type, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["callerId"] = from_str(self.caller_id) + result["type"] = to_enum(AssistantMessageToolRequestCallerType, self.type) + return result + + @dataclass class AssistantReasoningData: "Assistant reasoning content for timeline display with complete thinking text" @@ -8222,6 +8250,7 @@ class SessionResumeData: event_count: int resume_time: datetime already_in_use: bool | None = None + auto_tier: AutoTier | None = None context: WorkingDirectoryContext | None = None context_tier: ContextTier | None = None continue_pending_work: bool | None = None @@ -8240,6 +8269,7 @@ def from_dict(obj: Any) -> "SessionResumeData": event_count = from_int(obj.get("eventCount")) resume_time = from_datetime(obj.get("resumeTime")) already_in_use = from_union([from_none, from_bool], obj.get("alreadyInUse")) + auto_tier = from_union([from_none, lambda x: parse_enum(AutoTier, x)], obj.get("autoTier")) context = from_union([from_none, WorkingDirectoryContext.from_dict], obj.get("context")) context_tier = from_union([from_none, lambda x: parse_enum(ContextTier, x)], obj.get("contextTier")) continue_pending_work = from_union([from_none, from_bool], obj.get("continuePendingWork")) @@ -8255,6 +8285,7 @@ def from_dict(obj: Any) -> "SessionResumeData": event_count=event_count, resume_time=resume_time, already_in_use=already_in_use, + auto_tier=auto_tier, context=context, context_tier=context_tier, continue_pending_work=continue_pending_work, @@ -8274,6 +8305,8 @@ def to_dict(self) -> dict: result["resumeTime"] = to_datetime(self.resume_time) if self.already_in_use is not None: result["alreadyInUse"] = from_union([from_none, from_bool], self.already_in_use) + if self.auto_tier is not None: + result["autoTier"] = from_union([from_none, lambda x: to_enum(AutoTier, x)], self.auto_tier) if self.context is not None: result["context"] = from_union([from_none, lambda x: to_class(WorkingDirectoryContext, x)], self.context) if self.context_tier is not None: @@ -8566,6 +8599,7 @@ class SessionStartData: start_time: datetime version: int already_in_use: bool | None = None + auto_tier: AutoTier | None = None context: WorkingDirectoryContext | None = None context_tier: ContextTier | None = None detached_from_spawning_parent_session_id: str | None = None @@ -8586,6 +8620,7 @@ def from_dict(obj: Any) -> "SessionStartData": start_time = from_datetime(obj.get("startTime")) version = from_int(obj.get("version")) already_in_use = from_union([from_none, from_bool], obj.get("alreadyInUse")) + auto_tier = from_union([from_none, lambda x: parse_enum(AutoTier, x)], obj.get("autoTier")) context = from_union([from_none, WorkingDirectoryContext.from_dict], obj.get("context")) context_tier = from_union([from_none, lambda x: parse_enum(ContextTier, x)], obj.get("contextTier")) detached_from_spawning_parent_session_id = from_union([from_none, from_str], obj.get("detachedFromSpawningParentSessionId")) @@ -8603,6 +8638,7 @@ def from_dict(obj: Any) -> "SessionStartData": start_time=start_time, version=version, already_in_use=already_in_use, + auto_tier=auto_tier, context=context, context_tier=context_tier, detached_from_spawning_parent_session_id=detached_from_spawning_parent_session_id, @@ -8624,6 +8660,8 @@ def to_dict(self) -> dict: result["version"] = to_int(self.version) if self.already_in_use is not None: result["alreadyInUse"] = from_union([from_none, from_bool], self.already_in_use) + if self.auto_tier is not None: + result["autoTier"] = from_union([from_none, lambda x: to_enum(AutoTier, x)], self.auto_tier) if self.context is not None: result["context"] = from_union([from_none, lambda x: to_class(WorkingDirectoryContext, x)], self.context) if self.context_tier is not None: @@ -11637,6 +11675,11 @@ class AgentInterruptedCancelPhase(Enum): MID_STREAM = "mid_stream" +class AssistantMessageToolRequestCallerType(Enum): + "Hosted program caller type" + PROGRAM = "program" + + class AssistantMessageToolRequestType(Enum): "Tool call type: \"function\" for standard tool calls, \"custom\" for grammar-based tool calls. Defaults to \"function\" when absent." # Standard function-style tool call. @@ -11695,6 +11738,16 @@ class AutoModeSwitchResponse(Enum): NO = "no" +class AutoTier(Enum): + "Routing preference used when the session model is `auto`." + # Optimize for efficiency. + EFFICIENCY = "efficiency" + # Balance efficiency and intelligence. + BALANCE = "balance" + # Optimize for intelligence. + INTELLIGENCE = "intelligence" + + class AutopilotObjectiveChangedOperation(Enum): "The type of operation performed on the autopilot objective state file" # Autopilot objective state file was created for a new objective. @@ -12487,6 +12540,8 @@ def session_event_to_dict(x: SessionEvent) -> Any: "AssistantMessageServerTools", "AssistantMessageStartData", "AssistantMessageToolRequest", + "AssistantMessageToolRequestCaller", + "AssistantMessageToolRequestCallerType", "AssistantMessageToolRequestType", "AssistantReasoningData", "AssistantReasoningDeltaData", @@ -12530,6 +12585,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "AutoModeSwitchCompletedData", "AutoModeSwitchRequestedData", "AutoModeSwitchResponse", + "AutoTier", "AutopilotObjectiveChangedOperation", "AutopilotObjectiveChangedStatus", "BinaryAssetReference", diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index e22c888f23..6c2f9c8eaf 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -10,10 +10,11 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::session_events::{ - AbortReason, ContextTier, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerSource, - McpServerStatus, ModelChangeSource, OmittedBinaryOmittedReason, PermissionMode, - PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionLimitsConfig, SessionMode, - ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, Verbosity, + AbortReason, AutoTier, ContextTier, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, + McpServerSource, McpServerStatus, ModelChangeSource, OmittedBinaryOmittedReason, + PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionLimitsConfig, + SessionMode, ShutdownType, SkillSource, TaskCompletionOutcome, UserToolSessionApproval, + Verbosity, }; use crate::types::{RequestId, SessionEvent, SessionId}; @@ -202,6 +203,8 @@ pub mod rpc_methods { pub const SESSION_SEND: &str = "session.send"; /// `session.sendMessages` pub const SESSION_SENDMESSAGES: &str = "session.sendMessages"; + /// `session.sandbox.getEnforcementStatus` + pub const SESSION_SANDBOX_GETENFORCEMENTSTATUS: &str = "session.sandbox.getEnforcementStatus"; /// `session.sendSystemNotification` pub const SESSION_SENDSYSTEMNOTIFICATION: &str = "session.sendSystemNotification"; /// `session.abort` @@ -3019,6 +3022,9 @@ pub struct CanvasProviderUnregisterRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CapiSessionOptions { + /// Routing preference used when the session model is `auto`. The runtime persists the preference across cold resume. When omitted, the default routing behavior is used. Resuming an already-resident session cannot change its preference. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, /// Whether to use WebSocket transport for the CAPI Responses API. Enabled by default when the model advertises `ws:/responses` support; set to `false` to force the HTTP Responses transport in environments where WebSockets are blocked (e.g. behind a proxy). Setting this to `false` is equivalent to the `COPILOT_CLI_DISABLE_WEBSOCKET_RESPONSES` environment variable. #[serde(skip_serializing_if = "Option::is_none")] pub enable_web_socket_responses: Option, @@ -4411,6 +4417,9 @@ pub struct DiscoveredMcpServer { pub struct EnqueueCommandParams { /// Slash-prefixed command string to enqueue, e.g. '/compact' or '/model gpt-4'. Queued FIFO with any in-flight items; if the session is idle, processing kicks off immediately. pub command: String, + /// Optional user-facing text for the queue row. The command string is shown when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_text: Option, } /// Indicates whether the command was accepted into the local execution queue. @@ -14756,6 +14765,26 @@ pub struct SandboxConfig { pub user_policy: Option, } +/// Managed sandbox enforcement state for a 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 SandboxEnforcementStatus { + /// Whether an enforcement failure has permanently blocked the session. + pub blocked: bool, + /// The first sandbox enforcement failure that blocked the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Whether the effective managed policy requires an available sandbox backend. + pub required: bool, +} + /// Register an absolute-time scheduled prompt. /// ///
@@ -21992,6 +22021,41 @@ pub struct SessionSendMessagesResult { pub message_ids: Vec, } +/// Identifies the target 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 SessionSandboxGetEnforcementStatusParams { + /// Target session identifier + pub session_id: SessionId, +} + +/// Managed sandbox enforcement state for a 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 SessionSandboxGetEnforcementStatusResult { + /// Whether an enforcement failure has permanently blocked the session. + pub blocked: bool, + /// The first sandbox enforcement failure that blocked the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Whether the effective managed policy requires an available sandbox backend. + pub required: bool, +} + /// Result of aborting the current turn /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 4d5b7f1538..99fbd099ae 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -3185,6 +3185,13 @@ impl<'a> SessionRpc<'a> { } } + /// `session.sandbox.*` sub-namespace. + pub fn sandbox(&self) -> SessionRpcSandbox<'a> { + SessionRpcSandbox { + session: self.session, + } + } + /// `session.schedule.*` sub-namespace. pub fn schedule(&self) -> SessionRpcSchedule<'a> { SessionRpcSchedule { @@ -9413,6 +9420,42 @@ impl<'a> SessionRpcRemote<'a> { } } +/// `session.sandbox.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcSandbox<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcSandbox<'a> { + /// Returns whether managed policy requires sandbox enforcement and whether an enforcement failure has permanently blocked the session. + /// + /// Wire method: `session.sandbox.getEnforcementStatus`. + /// + /// # Returns + /// + /// Managed sandbox enforcement state for a session. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn get_enforcement_status(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_SANDBOX_GETENFORCEMENTSTATUS, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `session.schedule.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcSchedule<'a> { diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 79284b1671..266be4895a 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -939,6 +939,9 @@ pub struct SessionStartData { /// Whether the session was already in use by another client at start time #[serde(skip_serializing_if = "Option::is_none")] pub already_in_use: Option, + /// Auto routing preference selected at session creation time + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, /// Working directory and git context at session start #[serde(skip_serializing_if = "Option::is_none")] pub context: Option, @@ -988,6 +991,9 @@ pub struct SessionResumeData { /// Whether the session was already in use by another client at resume time #[serde(skip_serializing_if = "Option::is_none")] pub already_in_use: Option, + /// Auto routing preference active at resume time + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, /// Updated working directory and git context at resume time #[serde(skip_serializing_if = "Option::is_none")] pub context: Option, @@ -2527,6 +2533,16 @@ pub struct AssistantMessageServerTools { pub raw_content_blocks: Option>, } +/// Hosted program that requested this client tool call +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssistantMessageToolRequestCaller { + /// Provider-assigned identifier for the hosted caller. + pub caller_id: String, + /// Kind of hosted caller that requested the client tool call. + pub r#type: AssistantMessageToolRequestCallerType, +} + /// A tool invocation request from the assistant #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2534,6 +2550,9 @@ pub struct AssistantMessageToolRequest { /// Arguments to pass to the tool, format depends on the tool #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option, + /// Hosted program that requested this client tool call + #[serde(skip_serializing_if = "Option::is_none")] + pub caller: Option, /// Resolved intention summary describing what this specific call does #[serde(skip_serializing_if = "Option::is_none")] pub intention_summary: Option, @@ -6321,6 +6340,24 @@ pub struct McpAppToolCallCompleteData { pub tool_name: String, } +/// Routing preference used when the session model is `auto`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AutoTier { + /// Optimize for efficiency. + #[serde(rename = "efficiency")] + Efficiency, + /// Balance efficiency and intelligence. + #[serde(rename = "balance")] + Balance, + /// Optimize for intelligence. + #[serde(rename = "intelligence")] + Intelligence, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Hosting platform type of the repository (github or ado) #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum WorkingDirectoryContextHostType { @@ -6939,6 +6976,17 @@ pub enum CitationProvider { Unknown, } +/// Hosted program caller type +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AssistantMessageToolRequestCallerType { + #[serde(rename = "program")] + Program, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// API endpoint used for this model call, matching CAPI supported_endpoints vocabulary #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AssistantUsageApiEndpoint { diff --git a/rust/tests/e2e/commands.rs b/rust/tests/e2e/commands.rs index 2e15c24a0f..78c8e07616 100644 --- a/rust/tests/e2e/commands.rs +++ b/rust/tests/e2e/commands.rs @@ -210,6 +210,7 @@ async fn session_commands_enqueue_and_respond_to_queued_command() { .commands() .enqueue(EnqueueCommandParams { command: "/help".to_string(), + display_text: None, }) .await .expect("enqueue command"); diff --git a/rust/tests/e2e/rpc_queue.rs b/rust/tests/e2e/rpc_queue.rs index 6f4f881659..d5f1228302 100644 --- a/rust/tests/e2e/rpc_queue.rs +++ b/rust/tests/e2e/rpc_queue.rs @@ -153,6 +153,7 @@ async fn pendingitems_reports_queued_command_and_remove_and_clear_update_queue() .commands() .enqueue(EnqueueCommandParams { command: first_command, + display_text: None, }) .await .expect("enqueue command"); @@ -167,6 +168,7 @@ async fn pendingitems_reports_queued_command_and_remove_and_clear_update_queue() .commands() .enqueue(EnqueueCommandParams { command: second_command.clone(), + display_text: None, }) .await .expect("enqueue second command"); @@ -187,6 +189,7 @@ async fn pendingitems_reports_queued_command_and_remove_and_clear_update_queue() .commands() .enqueue(EnqueueCommandParams { command: third_command.clone(), + display_text: None, }) .await .expect("enqueue third command"); diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index bdb4d095b7..d4be520fe7 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -143,12 +143,22 @@ function placeholderToQuicktypeIdentifiers(placeholder: string): string[] { return [...new Set([basic, basic.replace(/Mcp/g, "MCP")])]; } -function postProcessExternalRefsForPython( +export function postProcessExternalRefsForPython( code: string, placeholderToReal: Map, externalEnumNames: Set = new Set() ): string { for (const [placeholder, realName] of placeholderToReal) { + const markerProperty = `__externalRefMarker_${placeholder}`; + const markerClass = [ + ...code.matchAll( + /(?:^|\n)(@dataclass\r?\nclass (\w+)\b[\s\S]*?)(?=\n@dataclass\b|\nclass\s+\w|\ndef\s+\w|$)/g + ), + ].find((match) => match[1].includes(`"${markerProperty}"`)); + if (markerClass) { + code = code.replace(markerClass[0], "\n"); + code = code.replace(new RegExp(`\\b${escapeRegExp(markerClass[2])}\\b`, "g"), realName); + } for (const quicktypeName of placeholderToQuicktypeIdentifiers(placeholder)) { code = code.replace( new RegExp( diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 710d725318..8e1ab03a06 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -472,8 +472,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82-0", - "integrity": "sha512-fSZVNAzFFYaS6btYD0+cKF7SrrtOklhpkPs/cIMZY7Fgxoa6rfZrlTWXlQNgNIdwLp51XxwTfxnZO+D9+MQ5yg==", + "version": "1.0.82", + "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { @@ -483,19 +483,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82-0", - "@github/copilot-darwin-x64": "1.0.82-0", - "@github/copilot-linux-arm64": "1.0.82-0", - "@github/copilot-linux-x64": "1.0.82-0", - "@github/copilot-linuxmusl-arm64": "1.0.82-0", - "@github/copilot-linuxmusl-x64": "1.0.82-0", - "@github/copilot-win32-arm64": "1.0.82-0", - "@github/copilot-win32-x64": "1.0.82-0" + "@github/copilot-darwin-arm64": "1.0.82", + "@github/copilot-darwin-x64": "1.0.82", + "@github/copilot-linux-arm64": "1.0.82", + "@github/copilot-linux-x64": "1.0.82", + "@github/copilot-linuxmusl-arm64": "1.0.82", + "@github/copilot-linuxmusl-x64": "1.0.82", + "@github/copilot-win32-arm64": "1.0.82", + "@github/copilot-win32-x64": "1.0.82" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-TzBYfyvxcw3z9Mu7U8TsFo/Nq7m5XS6ahT71aPL+gx/YId0kmenI27b9daXsK6LA1D0gsFGwNBDKINddqntt1g==", + "version": "1.0.82", + "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", "cpu": [ "arm64" ], @@ -510,8 +510,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82-0", - "integrity": "sha512-Lm/U5Q8kN8yEeBTWKTfIxXAgXaT6zqdBzAAO7lA4DWHZ92AEeZZiVTs6jEWsQ2aWB2uMs6zbn0vi6t+/jIqCGw==", + "version": "1.0.82", + "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", "cpu": [ "x64" ], @@ -526,8 +526,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-YERVMC1Q4p6l6KQHL5rVOI52rWbvgp9IwyzUBaVSGrfFuqu5BEvZ9bgHPsxTYi3Npkt5KVOXEyPMU5rAY09qQQ==", + "version": "1.0.82", + "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", "cpu": [ "arm64" ], @@ -542,8 +542,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82-0", - "integrity": "sha512-z2hxMVjqt4+xDRFTZv3/0K3X+aqcJhd6zPO2JxCpOVTh5CNZFaWk+XIa2iXAPWxFqdKJsQ4muXMl3zInaAOkRw==", + "version": "1.0.82", + "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", "cpu": [ "x64" ], @@ -558,8 +558,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-EcUCv2PKhBzCCvpTaS511VYTDWyhudyIRPvBpc9gFNO3hjlgiNDusf4k9vP6+E3/lHenwGYzMsyRHcYIOy4vcQ==", + "version": "1.0.82", + "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", "cpu": [ "arm64" ], @@ -574,8 +574,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82-0", - "integrity": "sha512-1fKVjUiZ1tdb0/d/re90EpFGXhlIPfjENp2Wo/2Kj592dWO3+IwM2qV/AOdMQ4pacW5iYHII7nibx1/EYq3LGQ==", + "version": "1.0.82", + "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", "cpu": [ "x64" ], @@ -590,8 +590,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82-0", - "integrity": "sha512-H341wuxQHhwe/yLmORHzwC3DGzFZgGzh+TpwfyK2zjeYVbwDZ3Bax8M+9CzIED9jIBdEfAmfGzxKUHFugpXTtQ==", + "version": "1.0.82", + "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", "cpu": [ "arm64" ], @@ -606,8 +606,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82-0", - "integrity": "sha512-f1ba3gG8NaoYWFHtHaHcLN4It7mclkWdCOXvwFPqPEwqCEIx/+Zh6VHiOeIcNWRS0elRP6QYDCKaTDy1TW27uQ==", + "version": "1.0.82", + "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index cceca0b959..c7e7647ac3 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -14,7 +14,7 @@ "node": "^20.19.0 || >=22.12.0" }, "devDependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.82", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From b79cef213bfc29d4573e21ec7037fa44c0f1d5d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:16:51 +0000 Subject: [PATCH 05/21] Update @github/copilot to 1.0.83-0 (#2449) * Update @github/copilot to 1.0.83-0 - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code * test: separate canvas and extension opt-ins Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Shay Rojansky Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 2 +- dotnet/src/Generated/SessionEvents.cs | 2 +- dotnet/test/E2E/CanvasE2ETests.cs | 1 - go/rpc/zrpc.go | 26 ++-- go/rpc/zsession_events.go | 2 +- java/pom.xml | 2 +- java/scripts/codegen/package-lock.json | 72 +++++----- java/scripts/codegen/package.json | 2 +- .../copilot/generated/HookStartEvent.java | 2 +- .../rpc/SessionUiEphemeralQueryParams.java | 4 +- .../generated/rpc/SessionsOpenCloud.java | 2 +- ...egisterExtensionToolsOnSessionOptions.java | 2 +- ...RegisterExtensionToolsOnSessionParams.java | 2 +- ...RegisterExtensionToolsOnSessionResult.java | 2 +- nodejs/package-lock.json | 54 +++---- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/rpc.ts | 14 +- nodejs/src/generated/session-events.ts | 2 +- python/copilot/generated/rpc.py | 134 ++++++++---------- rust/src/generated/api_types.rs | 14 +- rust/src/generated/rpc.rs | 2 +- rust/src/generated/session_events.rs | 2 +- rust/tests/e2e/canvas.rs | 1 - test/harness/package-lock.json | 54 +++---- test/harness/package.json | 2 +- 26 files changed, 193 insertions(+), 213 deletions(-) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index c97660f369..c7be742ee2 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -29565,7 +29565,7 @@ internal async Task ConnectAsync(bool? enableGitHubTelemetryForwa return await CopilotClient.InvokeRpcAsync(_rpc, "connect", [request], cancellationToken); } - /// Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime temporarily falls back to its built-in Node launcher for backward compatibility. + /// Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. /// The to monitor for cancellation requests. The default is . [Experimental(Diagnostics.Experimental)] public async Task RegisterExtensionLaunchProviderAsync(CancellationToken cancellationToken = default) diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 4c7ebc6ffa..6bfe202bff 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -4741,7 +4741,7 @@ public sealed partial class HookStartData [JsonPropertyName("hookType")] public required string HookType { get; set; } - /// Input data passed to the hook. + /// Input data passed to the hook. For postToolUse hooks the retained copy served by session.eventLog.read (and by a resumed session) elides the tool result's inline `contents`/`uiResource` and replaces an over-long `textResultForLlm` with a `[copilot:elided ...]` marker, to keep a multi-megabyte payload out of the durable event log; the live subscription stream still delivers the full value. Read the adjacent tool.execution_complete event for the tool result itself. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("input")] public JsonElement? Input { get; set; } diff --git a/dotnet/test/E2E/CanvasE2ETests.cs b/dotnet/test/E2E/CanvasE2ETests.cs index deb94a9b24..249440d993 100644 --- a/dotnet/test/E2E/CanvasE2ETests.cs +++ b/dotnet/test/E2E/CanvasE2ETests.cs @@ -123,7 +123,6 @@ private Task CreateCanvasSessionAsync(TestCanvasHandler handler) { OnPermissionRequest = PermissionHandler.ApproveAll, RequestCanvasRenderer = true, - RequestExtensions = true, ExtensionInfo = new ExtensionInfo { Source = "dotnet-sdk-tests", Name = "canvas-provider" }, Canvases = [ diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 706f32bf5f..67121c30ed 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -9944,10 +9944,8 @@ type RegisterExtensionLaunchProviderResult struct { // Internal: RegisterExtensionToolsParams is an internal SDK API and is not part of the // public surface. type RegisterExtensionToolsParams struct { - // In-process ExtensionLoader handle (CLI-only optimization). Marked internal: this field is - // excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, - // extension discovery/launch moves entirely into the runtime — the CLI passes pure config - // (search paths, disabled ids) via SessionOptions instead. + // In-process ExtensionLoader handle used only by the CLI and excluded from the public SDK + // surface. // Internal: Loader is part of the SDK's internal API surface and is not intended for // external use. Loader any `json:"loader"` @@ -9963,8 +9961,7 @@ type RegisterExtensionToolsParams struct { // Internal: RegisterExtensionToolsResult is an internal SDK API and is not part of the // public surface. type RegisterExtensionToolsResult struct { - // In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an - // explicit `extensions.unregister` RPC in the SDK migration. + // In-process unsubscribe function used only by the CLI. // Internal: Unsubscribe is part of the SDK's internal API surface and is not intended for // external use. Unsubscribe any `json:"unsubscribe"` @@ -12144,10 +12141,8 @@ func (SessionsOpenAttach) Kind() SessionOpenParamsKind { // Experimental: SessionsOpenCloud is part of an experimental API and may change or be // removed. type SessionsOpenCloud struct { - // In-process callback invoked when the cloud task is created (before connection). Marked - // internal because a function reference cannot cross the JSON-RPC boundary. Disappears in - // the SDK migration: the field is purely cosmetic (it flips a single CLI phase label from - // 'creating' to 'connecting') and the wire-clean version just drops the intermediate phase. + // In-process callback invoked when the cloud task is created, before connection. Internal + // because function references cannot cross the JSON-RPC boundary. // Internal: OnTaskCreated is part of the SDK's internal API surface and is not intended for // external use. OnTaskCreated any `json:"onTaskCreated,omitempty"` @@ -12881,8 +12876,7 @@ type SessionsPruneOldRequest struct { // Experimental: SessionsRegisterExtensionToolsOnSessionOptions is part of an experimental // API and may change or be removed. type SessionsRegisterExtensionToolsOnSessionOptions struct { - // In-process `() => boolean` gating callback (CLI-only optimization). Marked internal: - // replaced by runtime-side enable/disable RPCs in the SDK migration. + // In-process `() => boolean` gating callback used only by the CLI. // Internal: Enabled is part of the SDK's internal API surface and is not intended for // external use. Enabled any `json:"enabled,omitempty"` @@ -14739,14 +14733,12 @@ type UIElicitationStringOneOfFieldOneOf struct { // removed. type UIEphemeralQueryRequest struct { // In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. - // Marked internal: excluded from the public SDK surface. Replaced by an explicit - // cancellation token + cancel RPC in the SDK migration. + // Internal and excluded from the public SDK surface. // Internal: AbortSignal is part of the SDK's internal API surface and is not intended for // external use. AbortSignal any `json:"abortSignal,omitempty"` // In-process streaming callback `(text) => void` invoked with each token as the model emits - // it. Marked internal: excluded from the public SDK surface. In a process-separated SDK - // this is replaced by a streaming RPC that yields chunks and a final answer. + // it. Internal and excluded from the public SDK surface. // Internal: OnChunk is part of the SDK's internal API surface and is not intended for // external use. OnChunk any `json:"onChunk,omitempty"` @@ -20712,7 +20704,7 @@ func (a *ServerRPC) Ping(ctx context.Context, params *PingRequest) (*PingResult, // RegisterExtensionLaunchProvider registers the calling SDK client as the per-entrypoint // extension launch provider. Call before creating any sessions. When omitted, the runtime -// temporarily falls back to its built-in Node launcher for backward compatibility. +// uses its built-in extension launcher. // // RPC method: registerExtensionLaunchProvider. // Experimental: RegisterExtensionLaunchProvider is an experimental API and may change or be diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 07504e8815..acea96f5b9 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -1258,7 +1258,7 @@ type HookStartData struct { HookInvocationID string `json:"hookInvocationId"` // Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") HookType string `json:"hookType"` - // Input data passed to the hook + // Input data passed to the hook. For postToolUse hooks the retained copy served by session.eventLog.read (and by a resumed session) elides the tool result's inline `contents`/`uiResource` and replaces an over-long `textResultForLlm` with a `[copilot:elided ...]` marker, to keep a multi-megabyte payload out of the durable event log; the live subscription stream still delivers the full value. Read the adjacent tool.execution_complete event for the tool result itself. Input any `json:"input,omitempty"` // Tool call ID of the parent tool invocation when this event originates from a sub-agent ParentToolCallID *string `json:"parentToolCallId,omitempty"` diff --git a/java/pom.xml b/java/pom.xml index 5b493fcc55..427bc2320c 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -63,7 +63,7 @@ DO NOT EDIT MANUALLY. Updated by the update-copilot-dependency workflow. --> - ^1.0.82 + ^1.0.83-0 true diff --git a/java/scripts/codegen/package-lock.json b/java/scripts/codegen/package-lock.json index 71abcd72dc..fd177ca1e8 100644 --- a/java/scripts/codegen/package-lock.json +++ b/java/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.82", + "@github/copilot": "^1.0.83-0", "json-schema": "^0.4.0", "tsx": "^4.23.12" } @@ -428,9 +428,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82.tgz", - "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", + "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -439,20 +439,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82", - "@github/copilot-darwin-x64": "1.0.82", - "@github/copilot-linux-arm64": "1.0.82", - "@github/copilot-linux-x64": "1.0.82", - "@github/copilot-linuxmusl-arm64": "1.0.82", - "@github/copilot-linuxmusl-x64": "1.0.82", - "@github/copilot-win32-arm64": "1.0.82", - "@github/copilot-win32-x64": "1.0.82" + "@github/copilot-darwin-arm64": "1.0.83-0", + "@github/copilot-darwin-x64": "1.0.83-0", + "@github/copilot-linux-arm64": "1.0.83-0", + "@github/copilot-linux-x64": "1.0.83-0", + "@github/copilot-linuxmusl-arm64": "1.0.83-0", + "@github/copilot-linuxmusl-x64": "1.0.83-0", + "@github/copilot-win32-arm64": "1.0.83-0", + "@github/copilot-win32-x64": "1.0.83-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82.tgz", - "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", + "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82.tgz", - "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", + "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", "cpu": [ "x64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82.tgz", - "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", + "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82.tgz", - "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", + "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82.tgz", - "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", + "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82.tgz", - "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", + "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82.tgz", - "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", + "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", "cpu": [ "arm64" ], @@ -562,9 +562,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82.tgz", - "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", + "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", "cpu": [ "x64" ], diff --git a/java/scripts/codegen/package.json b/java/scripts/codegen/package.json index 1b33a27904..8089a79133 100644 --- a/java/scripts/codegen/package.json +++ b/java/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.82", + "@github/copilot": "^1.0.83-0", "json-schema": "^0.4.0", "tsx": "^4.23.12" } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/HookStartEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/HookStartEvent.java index 3b8b41fafb..030e08caf8 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/HookStartEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/HookStartEvent.java @@ -38,7 +38,7 @@ public record HookStartEventData( @JsonProperty("hookInvocationId") String hookInvocationId, /** Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") */ @JsonProperty("hookType") String hookType, - /** Input data passed to the hook */ + /** Input data passed to the hook. For postToolUse hooks the retained copy served by session.eventLog.read (and by a resumed session) elides the tool result's inline `contents`/`uiResource` and replaces an over-long `textResultForLlm` with a `[copilot:elided ...]` marker, to keep a multi-megabyte payload out of the durable event log; the live subscription stream still delivers the full value. Read the adjacent tool.execution_complete event for the tool result itself. */ @JsonProperty("input") Object input, /** Tool call ID of the parent tool invocation when this event originates from a sub-agent */ @JsonProperty("parentToolCallId") String parentToolCallId diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionUiEphemeralQueryParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionUiEphemeralQueryParams.java index b384238b28..0f02f7258d 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionUiEphemeralQueryParams.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionUiEphemeralQueryParams.java @@ -28,9 +28,9 @@ public record SessionUiEphemeralQueryParams( @JsonProperty("sessionId") String sessionId, /** Question to answer from the current conversation context. */ @JsonProperty("question") String question, - /** In-process streaming callback `(text) => void` invoked with each token as the model emits it. Marked internal: excluded from the public SDK surface. In a process-separated SDK this is replaced by a streaming RPC that yields chunks and a final answer. */ + /** In-process streaming callback `(text) => void` invoked with each token as the model emits it. Internal and excluded from the public SDK surface. */ @JsonProperty("onChunk") Object onChunk, - /** In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Marked internal: excluded from the public SDK surface. Replaced by an explicit cancellation token + cancel RPC in the SDK migration. */ + /** In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Internal and excluded from the public SDK surface. */ @JsonProperty("abortSignal") Object abortSignal ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsOpenCloud.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsOpenCloud.java index 8e4a74bd88..b0be6d42dd 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsOpenCloud.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsOpenCloud.java @@ -40,7 +40,7 @@ public final class SessionsOpenCloud extends SessionsOpenParams { @JsonProperty("options") private SessionOpenOptions options; - /** In-process callback invoked when the cloud task is created (before connection). Marked internal because a function reference cannot cross the JSON-RPC boundary. Disappears in the SDK migration: the field is purely cosmetic (it flips a single CLI phase label from 'creating' to 'connecting') and the wire-clean version just drops the intermediate phase. */ + /** In-process callback invoked when the cloud task is created, before connection. Internal because function references cannot cross the JSON-RPC boundary. */ @JsonProperty("onTaskCreated") private Object onTaskCreated; diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionOptions.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionOptions.java index 440c01b2a8..e19737e32e 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionOptions.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionOptions.java @@ -21,7 +21,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record SessionsRegisterExtensionToolsOnSessionOptions( - /** In-process `() => boolean` gating callback (CLI-only optimization). Marked internal: replaced by runtime-side enable/disable RPCs in the SDK migration. */ + /** In-process `() => boolean` gating callback used only by the CLI. */ @JsonProperty("enabled") Object enabled ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionParams.java index d7eb48f2b3..7fcb3322e9 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionParams.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionParams.java @@ -26,7 +26,7 @@ public record SessionsRegisterExtensionToolsOnSessionParams( /** Session to register extension tools on. */ @JsonProperty("sessionId") String sessionId, - /** In-process ExtensionLoader handle (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, extension discovery/launch moves entirely into the runtime — the CLI passes pure config (search paths, disabled ids) via SessionOptions instead. */ + /** In-process ExtensionLoader handle used only by the CLI and excluded from the public SDK surface. */ @JsonProperty("loader") Object loader, /** Optional registration options. */ @JsonProperty("options") SessionsRegisterExtensionToolsOnSessionOptions options diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionResult.java index 63cc5fb0fb..5cb5af063b 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionsRegisterExtensionToolsOnSessionResult.java @@ -24,7 +24,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record SessionsRegisterExtensionToolsOnSessionResult( - /** In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an explicit `extensions.unregister` RPC in the SDK migration. */ + /** In-process unsubscribe function used only by the CLI. */ @JsonProperty("unsubscribe") Object unsubscribe ) { } diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 6cefab6031..6480e85e29 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82", + "@github/copilot": "^1.0.83-0", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -658,8 +658,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82", - "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", + "version": "1.0.83-0", + "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -668,19 +668,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82", - "@github/copilot-darwin-x64": "1.0.82", - "@github/copilot-linux-arm64": "1.0.82", - "@github/copilot-linux-x64": "1.0.82", - "@github/copilot-linuxmusl-arm64": "1.0.82", - "@github/copilot-linuxmusl-x64": "1.0.82", - "@github/copilot-win32-arm64": "1.0.82", - "@github/copilot-win32-x64": "1.0.82" + "@github/copilot-darwin-arm64": "1.0.83-0", + "@github/copilot-darwin-x64": "1.0.83-0", + "@github/copilot-linux-arm64": "1.0.83-0", + "@github/copilot-linux-x64": "1.0.83-0", + "@github/copilot-linuxmusl-arm64": "1.0.83-0", + "@github/copilot-linuxmusl-x64": "1.0.83-0", + "@github/copilot-win32-arm64": "1.0.83-0", + "@github/copilot-win32-x64": "1.0.83-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82", - "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", + "version": "1.0.83-0", + "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", "cpu": [ "arm64" ], @@ -694,8 +694,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82", - "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", + "version": "1.0.83-0", + "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", "cpu": [ "x64" ], @@ -709,8 +709,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82", - "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", + "version": "1.0.83-0", + "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", "cpu": [ "arm64" ], @@ -724,8 +724,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82", - "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", + "version": "1.0.83-0", + "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", "cpu": [ "x64" ], @@ -739,8 +739,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82", - "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", + "version": "1.0.83-0", + "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", "cpu": [ "arm64" ], @@ -754,8 +754,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82", - "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", + "version": "1.0.83-0", + "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", "cpu": [ "x64" ], @@ -769,8 +769,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82", - "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", + "version": "1.0.83-0", + "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", "cpu": [ "arm64" ], @@ -784,8 +784,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82", - "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", + "version": "1.0.83-0", + "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index ff54fb3682..e1744c4fdc 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82", + "@github/copilot": "^1.0.83-0", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 16a17c7d21..0ef1a077b2 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82", + "@github/copilot": "^1.0.83-0", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 024dd3a5bb..32a002dd01 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -16047,7 +16047,7 @@ export interface RegisterExtensionToolsParams { */ sessionId: string; /** - * In-process ExtensionLoader handle (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, extension discovery/launch moves entirely into the runtime — the CLI passes pure config (search paths, disabled ids) via SessionOptions instead. + * In-process ExtensionLoader handle used only by the CLI and excluded from the public SDK surface. * * @internal * @@ -16065,7 +16065,7 @@ export interface RegisterExtensionToolsParams { /** @experimental */ export interface SessionsRegisterExtensionToolsOnSessionOptions { /** - * In-process `() => boolean` gating callback (CLI-only optimization). Marked internal: replaced by runtime-side enable/disable RPCs in the SDK migration. + * In-process `() => boolean` gating callback used only by the CLI. * * @internal */ @@ -16081,7 +16081,7 @@ export interface SessionsRegisterExtensionToolsOnSessionOptions { /** @internal */ export interface RegisterExtensionToolsResult { /** - * In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an explicit `extensions.unregister` RPC in the SDK migration. + * In-process unsubscribe function used only by the CLI. * * @internal * @@ -18660,7 +18660,7 @@ export interface SessionsOpenCloud { owner?: string; options?: SessionOpenOptions; /** - * In-process callback invoked when the cloud task is created (before connection). Marked internal because a function reference cannot cross the JSON-RPC boundary. Disappears in the SDK migration: the field is purely cosmetic (it flips a single CLI phase label from 'creating' to 'connecting') and the wire-clean version just drops the intermediate phase. + * In-process callback invoked when the cloud task is created, before connection. Internal because function references cannot cross the JSON-RPC boundary. * * @internal */ @@ -21644,13 +21644,13 @@ export interface UIEphemeralQueryRequest { */ question: string; /** - * In-process streaming callback `(text) => void` invoked with each token as the model emits it. Marked internal: excluded from the public SDK surface. In a process-separated SDK this is replaced by a streaming RPC that yields chunks and a final answer. + * In-process streaming callback `(text) => void` invoked with each token as the model emits it. Internal and excluded from the public SDK surface. * * @internal */ onChunk?: OpaqueInProcessValue; /** - * In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Marked internal: excluded from the public SDK surface. Replaced by an explicit cancellation token + cancel RPC in the SDK migration. + * In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Internal and excluded from the public SDK surface. * * @internal */ @@ -23001,7 +23001,7 @@ export function createServerRpc(connection: MessageConnection) { connection.sendRequest("extensions.disable", params), }, /** - * Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime temporarily falls back to its built-in Node launcher for backward compatibility. + * Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. * * @experimental */ diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index cb845443aa..c7a610abef 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -7019,7 +7019,7 @@ export interface HookStartData { */ hookType: string; /** - * Input data passed to the hook + * Input data passed to the hook. For postToolUse hooks the retained copy served by session.eventLog.read (and by a resumed session) elides the tool result's inline `contents`/`uiResource` and replaces an over-long `textResultForLlm` with a `[copilot:elided ...]` marker, to keep a multi-megabyte payload out of the durable event log; the live subscription stream still delivers the full value. Read the adjacent tool.execution_complete event for the tool result itself. */ input?: JsonValue; /** diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 84f3718b16..1340d726be 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -9175,9 +9175,7 @@ class SessionsRegisterExtensionToolsOnSessionOptions: # Internal: this field is an internal SDK API and is not part of the public surface. enabled: Any = None - """In-process `() => boolean` gating callback (CLI-only optimization). Marked internal: - replaced by runtime-side enable/disable RPCs in the SDK migration. - """ + """In-process `() => boolean` gating callback used only by the CLI.""" @staticmethod def from_dict(obj: Any) -> 'SessionsRegisterExtensionToolsOnSessionOptions': @@ -9191,6 +9189,26 @@ def to_dict(self) -> dict: result["enabled"] = self.enabled return result +# Experimental: this type is part of an experimental API and may change or be removed. +# Internal: this type is an internal SDK API and is not part of the public surface. +@dataclass +class _RegisterExtensionToolsResult: + """Handle for releasing the extension tool registration.""" + + unsubscribe: Any + """In-process unsubscribe function used only by the CLI.""" + + @staticmethod + def from_dict(obj: Any) -> '_RegisterExtensionToolsResult': + assert isinstance(obj, dict) + unsubscribe = obj.get("unsubscribe") + return _RegisterExtensionToolsResult(unsubscribe) + + def to_dict(self) -> dict: + result: dict = {} + result["unsubscribe"] = self.unsubscribe + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ReleaseEventInterestParams: @@ -13718,6 +13736,42 @@ class UIElicitationSchemaPropertyNumberType(Enum): INTEGER = "integer" NUMBER = "number" +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class UIEphemeralQueryRequest: + """Transient question to answer without adding it to conversation history.""" + + question: str + """Question to answer from the current conversation context.""" + + # Internal: this field is an internal SDK API and is not part of the public surface. + abort_signal: Any = None + """In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. + Internal and excluded from the public SDK surface. + """ + # Internal: this field is an internal SDK API and is not part of the public surface. + on_chunk: Any = None + """In-process streaming callback `(text) => void` invoked with each token as the model emits + it. Internal and excluded from the public SDK surface. + """ + + @staticmethod + def from_dict(obj: Any) -> 'UIEphemeralQueryRequest': + assert isinstance(obj, dict) + question = from_str(obj.get("question")) + abort_signal = obj.get("abortSignal") + on_chunk = obj.get("onChunk") + return UIEphemeralQueryRequest(question, abort_signal, on_chunk) + + def to_dict(self) -> dict: + result: dict = {} + result["question"] = from_str(self.question) + if self.abort_signal is not None: + result["abortSignal"] = self.abort_signal + if self.on_chunk is not None: + result["onChunk"] = self.on_chunk + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class UIEphemeralQueryResult: @@ -22323,10 +22377,8 @@ class _RegisterExtensionToolsParams: """Params to attach an extension loader's tools to a session.""" loader: Any - """In-process ExtensionLoader handle (CLI-only optimization). Marked internal: this field is - excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, - extension discovery/launch moves entirely into the runtime — the CLI passes pure config - (search paths, disabled ids) via SessionOptions instead. + """In-process ExtensionLoader handle used only by the CLI and excluded from the public SDK + surface. """ session_id: str """Session to register extension tools on.""" @@ -34223,28 +34275,6 @@ def to_dict(self) -> dict: result["title"] = from_union([from_str, from_none], self.title) return result -# Experimental: this type is part of an experimental API and may change or be removed. -# Internal: this type is an internal SDK API and is not part of the public surface. -@dataclass -class _RegisterExtensionToolsResult: - """Handle for releasing the extension tool registration.""" - - unsubscribe: Any - """In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an - explicit `extensions.unregister` RPC in the SDK migration. - """ - - @staticmethod - def from_dict(obj: Any) -> '_RegisterExtensionToolsResult': - assert isinstance(obj, dict) - unsubscribe = obj.get("unsubscribe") - return _RegisterExtensionToolsResult(unsubscribe) - - def to_dict(self) -> dict: - result: dict = {} - result["unsubscribe"] = self.unsubscribe - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionLimitPredictionDetails: @@ -34505,10 +34535,8 @@ class SessionsOpenCloud: # Internal: this field is an internal SDK API and is not part of the public surface. on_task_created: Any = None - """In-process callback invoked when the cloud task is created (before connection). Marked - internal because a function reference cannot cross the JSON-RPC boundary. Disappears in - the SDK migration: the field is purely cosmetic (it flips a single CLI phase label from - 'creating' to 'connecting') and the wire-clean version just drops the intermediate phase. + """In-process callback invoked when the cloud task is created, before connection. Internal + because function references cannot cross the JSON-RPC boundary. """ options: SessionOpenOptions | None = None """Session options for cloud session creation.""" @@ -34970,44 +34998,6 @@ def to_dict(self) -> dict: result["tools"] = from_list(lambda x: to_class(ProtocolExternalToolDefinition, x), self.tools) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class UIEphemeralQueryRequest: - """Transient question to answer without adding it to conversation history.""" - - question: str - """Question to answer from the current conversation context.""" - - # Internal: this field is an internal SDK API and is not part of the public surface. - abort_signal: Any = None - """In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. - Marked internal: excluded from the public SDK surface. Replaced by an explicit - cancellation token + cancel RPC in the SDK migration. - """ - # Internal: this field is an internal SDK API and is not part of the public surface. - on_chunk: Any = None - """In-process streaming callback `(text) => void` invoked with each token as the model emits - it. Marked internal: excluded from the public SDK surface. In a process-separated SDK - this is replaced by a streaming RPC that yields chunks and a final answer. - """ - - @staticmethod - def from_dict(obj: Any) -> 'UIEphemeralQueryRequest': - assert isinstance(obj, dict) - question = from_str(obj.get("question")) - abort_signal = obj.get("abortSignal") - on_chunk = obj.get("onChunk") - return UIEphemeralQueryRequest(question, abort_signal, on_chunk) - - def to_dict(self) -> dict: - result: dict = {} - result["question"] = from_str(self.question) - if self.abort_signal is not None: - result["abortSignal"] = self.abort_signal - if self.on_chunk is not None: - result["onChunk"] = self.on_chunk - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class UpdateSubagentSettingsRequest: @@ -39625,7 +39615,7 @@ async def ping(self, params: PingRequest, *, timeout: float | None = None) -> Pi return PingResult.from_dict(await self._client.request("ping", params_dict, **_timeout_kwargs(timeout))) async def register_extension_launch_provider(self, *, timeout: float | None = None) -> None: - "Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime temporarily falls back to its built-in Node launcher for backward compatibility.\n\n.. warning:: This API is experimental and may change or be removed in future versions." + "Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher.\n\n.. warning:: This API is experimental and may change or be removed in future versions." await self._client.request("registerExtensionLaunchProvider", {}, **_timeout_kwargs(timeout)) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 6c2f9c8eaf..701ae61403 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -14173,7 +14173,7 @@ pub struct RegisterEventInterestResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionsRegisterExtensionToolsOnSessionOptions { - /// In-process `() => boolean` gating callback (CLI-only optimization). Marked internal: replaced by runtime-side enable/disable RPCs in the SDK migration. + /// In-process `() => boolean` gating callback used only by the CLI. #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] pub(crate) enabled: Option, @@ -14190,7 +14190,7 @@ pub struct SessionsRegisterExtensionToolsOnSessionOptions { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RegisterExtensionToolsParams { - /// In-process ExtensionLoader handle (CLI-only optimization). Marked internal: this field is excluded from the public SDK surface. When the CLI migrates to a process-separated SDK, extension discovery/launch moves entirely into the runtime — the CLI passes pure config (search paths, disabled ids) via SessionOptions instead. + /// In-process ExtensionLoader handle used only by the CLI and excluded from the public SDK surface. #[doc(hidden)] pub(crate) loader: serde_json::Value, /// Optional registration options. @@ -14211,7 +14211,7 @@ pub(crate) struct RegisterExtensionToolsParams { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RegisterExtensionToolsResult { - /// In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an explicit `extensions.unregister` RPC in the SDK migration. + /// In-process unsubscribe function used only by the CLI. #[doc(hidden)] pub(crate) unsubscribe: serde_json::Value, } @@ -17062,7 +17062,7 @@ pub struct SessionsOpenRemote { pub struct SessionsOpenCloud { /// Create a new cloud (coding-agent) session. pub kind: SessionsOpenCloudKind, - /// In-process callback invoked when the cloud task is created (before connection). Marked internal because a function reference cannot cross the JSON-RPC boundary. Disappears in the SDK migration: the field is purely cosmetic (it flips a single CLI phase label from 'creating' to 'connecting') and the wire-clean version just drops the intermediate phase. + /// In-process callback invoked when the cloud task is created, before connection. Internal because function references cannot cross the JSON-RPC boundary. #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] pub(crate) on_task_created: Option, @@ -20231,11 +20231,11 @@ pub struct UIElicitationStringOneOfField { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UIEphemeralQueryRequest { - /// In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Marked internal: excluded from the public SDK surface. Replaced by an explicit cancellation token + cancel RPC in the SDK migration. + /// In-process `AbortSignal` forwarded to the model client to cancel an in-flight request. Internal and excluded from the public SDK surface. #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] pub(crate) abort_signal: Option, - /// In-process streaming callback `(text) => void` invoked with each token as the model emits it. Marked internal: excluded from the public SDK surface. In a process-separated SDK this is replaced by a streaming RPC that yields chunks and a final answer. + /// In-process streaming callback `(text) => void` invoked with each token as the model emits it. Internal and excluded from the public SDK surface. #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] pub(crate) on_chunk: Option, @@ -21971,7 +21971,7 @@ pub struct SessionsGetRemoteControlStatusResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SessionsRegisterExtensionToolsOnSessionResult { - /// In-process unsubscribe function (CLI-only optimization). Marked internal: replaced by an explicit `extensions.unregister` RPC in the SDK migration. + /// In-process unsubscribe function used only by the CLI. #[doc(hidden)] pub(crate) unsubscribe: serde_json::Value, } diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 99fbd099ae..6f5895d856 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -211,7 +211,7 @@ impl<'a> ClientRpc<'a> { Ok(serde_json::from_value(_value)?) } - /// Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime temporarily falls back to its built-in Node launcher for backward compatibility. + /// Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. /// /// Wire method: `registerExtensionLaunchProvider`. /// diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 266be4895a..4b04b03444 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -4042,7 +4042,7 @@ pub struct HookStartData { pub hook_invocation_id: String, /// Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") pub hook_type: String, - /// Input data passed to the hook + /// Input data passed to the hook. For postToolUse hooks the retained copy served by session.eventLog.read (and by a resumed session) elides the tool result's inline `contents`/`uiResource` and replaces an over-long `textResultForLlm` with a `[copilot:elided ...]` marker, to keep a multi-megabyte payload out of the durable event log; the live subscription stream still delivers the full value. Read the adjacent tool.execution_complete event for the tool result itself. #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, /// Tool call ID of the parent tool invocation when this event originates from a sub-agent diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs index 2418e9e5a6..a2873eed23 100644 --- a/rust/tests/e2e/canvas.rs +++ b/rust/tests/e2e/canvas.rs @@ -64,7 +64,6 @@ fn canvas_session_config( ctx.approve_all_session_config() .with_request_canvas_renderer(true) - .with_request_extensions(true) .with_extension_info(ExtensionInfo::new("rust-sdk-tests", "canvas-provider")) .with_canvases([decl]) .with_canvas_handler(handler) diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 8e1ab03a06..a83d73d0a9 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.82", + "@github/copilot": "^1.0.83-0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -472,8 +472,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82", - "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", + "version": "1.0.83-0", + "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { @@ -483,19 +483,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82", - "@github/copilot-darwin-x64": "1.0.82", - "@github/copilot-linux-arm64": "1.0.82", - "@github/copilot-linux-x64": "1.0.82", - "@github/copilot-linuxmusl-arm64": "1.0.82", - "@github/copilot-linuxmusl-x64": "1.0.82", - "@github/copilot-win32-arm64": "1.0.82", - "@github/copilot-win32-x64": "1.0.82" + "@github/copilot-darwin-arm64": "1.0.83-0", + "@github/copilot-darwin-x64": "1.0.83-0", + "@github/copilot-linux-arm64": "1.0.83-0", + "@github/copilot-linux-x64": "1.0.83-0", + "@github/copilot-linuxmusl-arm64": "1.0.83-0", + "@github/copilot-linuxmusl-x64": "1.0.83-0", + "@github/copilot-win32-arm64": "1.0.83-0", + "@github/copilot-win32-x64": "1.0.83-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82", - "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", + "version": "1.0.83-0", + "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", "cpu": [ "arm64" ], @@ -510,8 +510,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82", - "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", + "version": "1.0.83-0", + "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", "cpu": [ "x64" ], @@ -526,8 +526,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82", - "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", + "version": "1.0.83-0", + "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", "cpu": [ "arm64" ], @@ -542,8 +542,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82", - "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", + "version": "1.0.83-0", + "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", "cpu": [ "x64" ], @@ -558,8 +558,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82", - "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", + "version": "1.0.83-0", + "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", "cpu": [ "arm64" ], @@ -574,8 +574,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82", - "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", + "version": "1.0.83-0", + "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", "cpu": [ "x64" ], @@ -590,8 +590,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82", - "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", + "version": "1.0.83-0", + "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", "cpu": [ "arm64" ], @@ -606,8 +606,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82", - "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", + "version": "1.0.83-0", + "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index c7e7647ac3..c2081214ab 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -14,7 +14,7 @@ "node": "^20.19.0 || >=22.12.0" }, "devDependencies": { - "@github/copilot": "^1.0.82", + "@github/copilot": "^1.0.83-0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From 01b27f16c52db654918d6d831c9ae5d24b47bf14 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 31 Aug 2026 16:50:08 +0000 Subject: [PATCH 06/21] [SDK/Factories] Add Factory Pagination And Completion Options (#2431) * [SDK/Factories] Add Factory Pagination And Completion Options Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add factory convenience E2E coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Clarify factory completion notification guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Isolate factory E2E completion notifications Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 + nodejs/docs/factories.md | 29 +++- nodejs/src/extension.ts | 2 + nodejs/src/factory.ts | 38 ++++++ nodejs/src/index.ts | 2 + nodejs/src/session.ts | 12 +- nodejs/test/e2e/factory.e2e.test.ts | 128 ++++++++++++++++-- .../test/e2e/fixtures/factory-extension.mjs | 17 +++ nodejs/test/factory.test.ts | 54 +++++++- 9 files changed, 269 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d695ed6fb6..345308849c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu ## [Unreleased] +### Feature: Node Agent Factories pagination and run notifications + +The experimental Node.js Agent Factories convenience API now supports paginated run history. Existing `session.factory.listRuns()` calls still return the runs array, while calls with `afterSeq`, `beforeSeq`, or `limit` return the full page with cursor and truncation metadata. + +Factory `run` and `resume` options now accept `notifyOnComplete` and `logPhaseNames`. The SDK forwards these options to the Copilot CLI for new and resumed runs. + ### Feature: rotating session-scoped GitHub credentials All six SDKs can now acquire short-lived GitHub credentials through a session-scoped callback. The SDK registers the callback before session create or resume, maps `initial` and `refresh` requests to the owning session, and removes registrations on rollback, replacement, session close, and client close. Static per-session `gitHubToken` credentials remain supported and are mutually exclusive with the callback. diff --git a/nodejs/docs/factories.md b/nodejs/docs/factories.md index 903a3de8b1..23b9d0fed3 100644 --- a/nodejs/docs/factories.md +++ b/nodejs/docs/factories.md @@ -139,6 +139,8 @@ Run by registered name or handle: const run = await session.factory.run("review-changed", { args: { files: ["src/a.ts"] }, limits: { maxAiCredits: 3 }, + notifyOnComplete: true, + logPhaseNames: true, }); if (run.status === "completed") { @@ -153,7 +155,12 @@ The name overload is: ```ts session.factory.run( name: string, - options?: { args?: JsonValue; limits?: FactoryLimits }, + options?: { + args?: JsonValue; + limits?: FactoryLimits; + notifyOnComplete?: boolean; + logPhaseNames?: boolean; + }, ): Promise; ``` @@ -162,6 +169,8 @@ Resume by run ID without resending the name or arguments: ```ts const run = await session.factory.resume(runId, { limits: { maxAiCredits: 6 }, + notifyOnComplete: true, + logPhaseNames: true, }); ``` @@ -170,10 +179,16 @@ The signature is: ```ts session.factory.resume( runId: string, - options?: { limits?: FactoryLimits }, + options?: { + limits?: FactoryLimits; + notifyOnComplete?: boolean; + logPhaseNames?: boolean; + }, ): Promise; ``` +Set `notifyOnComplete` to `true` for factories that are likely to be invoked by an agent, so the originating session is notified when the factory completes. Set it to `false` for factories intended to be invoked programmatically, where the caller awaits the result directly. Set `logPhaseNames` to emit factory phase names to the session transcript. Both options apply to new and resumed runs. + Both resolve with the run envelope (`FactoryRunResult`) for **every** outcome — `completed`, `error`, `halted`, and `cancelled` alike. Inspect `status` and read `result` only when the run completed; a limit breach carries a typed `failure`. SDK-initiated `run` and `resume` do not request permission, so they have no declined outcome. The model's `run_factory` tool requests permission before the durable row exists; declining it creates no run row. An SDK-initiated run is refused only when the session already has its maximum number of active top-level runs. Pre-execution resume failures throw `FactoryResumeError`, whose `code` is one of `not_found`, `non_resumable`, `already_active`, `factory_already_running`, `factory_limits_invalid`, `factory_session_disposed`, `factory_storage_unavailable`, or `factory_storage_corrupt`. An agent that no longer has a prior run's ID in context can recover it with `factories_manage` and `operation: "runs"`, which lists the session's factory runs with their IDs and statuses. This matters for resume: a run that reached a limit keeps its journal, so resuming it replays completed work for free, while restarting it from scratch pays for that work twice. @@ -219,8 +234,13 @@ The calling session can inspect its own factory runs: ```ts const runs = await session.factory.listRuns(); +const runsPage = await session.factory.listRuns({ + afterSeq, + beforeSeq, + limit, +}); const detail = await session.factory.getRunDetail(runId); -const page = await session.factory.getRunProgress(runId, { +const progressPage = await session.factory.getRunProgress(runId, { phaseId, afterSeq, beforeSeq, @@ -228,7 +248,8 @@ const page = await session.factory.getRunProgress(runId, { }); ``` -- `listRuns()` returns the newest default page of this session's durable factory runs. +- `listRuns()` returns only the runs array from the newest default page of this session's durable factory runs. This overload preserves the original convenience API. +- `listRuns({ afterSeq, beforeSeq, limit })` returns the full page. Its `oldestSeq`, `newestSeq`, `hasMoreNewer`, and `omittedOlder` fields let callers continue paging without raw RPC calls. - `getRunDetail(runId)` returns phases, prompt-safe agent summaries, and the latest progress page. - `getRunProgress(runId, options?)` pages progress forward, backward, by phase, or from the latest tail. diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index d756308734..ac0ccdb7ce 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -85,6 +85,8 @@ export { type FactoryRunResult, type FactoryRunStatus, type FactoryRunSummary, + type FactoryListRunsOptions, + type FactoryRunsPage, type FactoryRunDetail, type FactoryProgressPage, type FactoryProgressLine, diff --git a/nodejs/src/factory.ts b/nodejs/src/factory.ts index 8a6c787471..6212f462b4 100644 --- a/nodejs/src/factory.ts +++ b/nodejs/src/factory.ts @@ -4,6 +4,8 @@ import type { FactoryGetRunProgressRequest, + FactoryListRunsRequest, + FactoryListRunsResult, FactoryProgressPage, FactoryRunDetail, FactoryRunResult, @@ -26,6 +28,22 @@ export type { FactoryRunSummary, } from "./generated/rpc.js"; +/** + * Options for paging durable factory runs. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export type FactoryListRunsOptions = FactoryListRunsRequest; + +/** + * A page of durable factory runs and its paging metadata. + * + * @experimental Part of the experimental Agent Factories surface and may + * change or be removed in future SDK or CLI releases. + */ +export type FactoryRunsPage = FactoryListRunsResult; + /** * Run statuses a factory run can no longer move away from. * @@ -242,6 +260,10 @@ export interface RunOptions { args?: TArgs; /** Optional per-invocation resource ceiling overrides. */ limits?: FactoryLimits; + /** Whether to notify the originating session when the factory completes. */ + notifyOnComplete?: boolean; + /** Whether to emit factory phase names to the session transcript. */ + logPhaseNames?: boolean; /** * Prior run whose persisted identity, arguments, journal, and accounting should be resumed. * @@ -259,6 +281,10 @@ export interface RunOptions { export interface ResumeOptions { /** Optional per-invocation resource ceiling overrides. */ limits?: FactoryLimits; + /** Whether to notify the originating session when the factory completes. */ + notifyOnComplete?: boolean; + /** Whether to emit factory phase names to the session transcript. */ + logPhaseNames?: boolean; } /** @@ -328,8 +354,20 @@ export interface SessionFactoryApi { waitForRun(runId: string, options?: { signal?: AbortSignal }): Promise; /** * List the newest default page of this session's durable factory runs. + * + * This backwards-compatible overload returns only the runs array. Pass + * paging options to receive the full page, including its cursors and + * truncation metadata. */ listRuns(): Promise; + /** + * Page this session's durable factory runs. + * + * `afterSeq` and `beforeSeq` are exclusive cursors. The result includes + * `oldestSeq`, `newestSeq`, `hasMoreNewer`, and `omittedOlder` so callers + * can continue paging without using the raw RPC client. + */ + listRuns(options: FactoryListRunsOptions): Promise; /** Read durable phases, direct agents, and the latest progress tail for a run. */ getRunDetail(runId: string): Promise; /** Page durable progress forward, backward, or from the latest tail. */ diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 9d55ab1d10..1f4359ff8c 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -209,6 +209,8 @@ export type { FactoryRunResult, FactoryRunStatus, FactoryRunSummary, + FactoryListRunsOptions, + FactoryRunsPage, FactoryRunDetail, FactoryProgressPage, FactoryProgressLine, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 65ff00921c..eb4c6b7561 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -71,6 +71,7 @@ import { FactoryResumeError, isFactoryRunTerminal, type FactoryResumeErrorCode, + type FactoryListRunsOptions, type FactoryRunResult, type FactoryAgentOptions, type RunOptions, @@ -462,6 +463,8 @@ export class CopilotSession { if (options?.resumeFromRunId !== undefined) { return this.factory.resume(options.resumeFromRunId, { limits: options.limits, + notifyOnComplete: options.notifyOnComplete, + logPhaseNames: options.logPhaseNames, }); } const envelope = await this.rpc.factory.run({ @@ -469,6 +472,8 @@ export class CopilotSession { args: options?.args === undefined ? {} : options.args, options: { limits: options?.limits, + notifyOnComplete: options?.notifyOnComplete, + logPhaseNames: options?.logPhaseNames, }, }); @@ -481,6 +486,8 @@ export class CopilotSession { response = await this.rpc.factory.resume({ runId, limits: options?.limits, + notifyOnComplete: options?.notifyOnComplete, + logPhaseNames: options?.logPhaseNames, }); } catch (error) { if ( @@ -499,7 +506,10 @@ export class CopilotSession { }) as SessionFactoryApi["resume"], getRun: async (runId) => this.rpc.factory.getRun({ runId }), waitForRun: (runId, options) => this.waitForFactoryRun(runId, options?.signal), - listRuns: async () => (await this.rpc.factory.listRuns({})).runs, + listRuns: (async (options?: FactoryListRunsOptions) => { + const page = await this.rpc.factory.listRuns(options ?? {}); + return options === undefined ? page.runs : page; + }) as SessionFactoryApi["listRuns"], getRunDetail: (runId) => this.rpc.factory.getRunDetail({ runId }), getRunProgress: (runId, options = {}) => this.rpc.factory.getRunProgress({ runId, ...options }), diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index cddd8e47b0..669a1f9c64 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -84,6 +84,7 @@ it.skipIf(isInProcessTransport)( const result = await session.factory.run("argument-echo", { args: { source: "sdk-e2e", count: 11 }, + notifyOnComplete: false, }); expect(result).toMatchObject({ @@ -140,7 +141,7 @@ it.skipIf(isInProcessTransport)( const { workDir } = factoryTestContext; await using session = await setupFactoryExtension(workDir); - const run = await session.factory.run("argument-echo"); + const run = await session.factory.run("argument-echo", { notifyOnComplete: false }); const error = await session.factory.resume(run.runId).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(FactoryResumeError); @@ -148,6 +149,101 @@ it.skipIf(isInProcessTransport)( } ); +it.skipIf(isInProcessTransport)( + "forwards factory runtime controls across the SDK process boundary", + async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const suppressed = await session.factory.run("phased", { + notifyOnComplete: false, + logPhaseNames: false, + }); + + expect(suppressed).toMatchObject({ + status: "completed", + result: "finished", + }); + const progress = await session.factory.getRunProgress(suppressed.runId); + expect(progress.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "phase", text: "Collect" }), + expect.objectContaining({ kind: "log", text: "Collected" }), + expect.objectContaining({ kind: "phase", text: "Summarize" }), + expect.objectContaining({ kind: "log", text: "Summarized" }), + ]) + ); + + const events = await session.getEvents(); + expect( + events.some( + (event) => + event.type === "system.notification" && + event.data.kind.type === "factory_completed" && + event.data.kind.runId === suppressed.runId + ) + ).toBe(false); + expect( + events.filter( + (event) => event.type === "session.info" && event.data.infoType === "factory_phase" + ) + ).toEqual([]); + } +); + +it.skipIf(isInProcessTransport)("pages factory runs and returns cursor metadata", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const first = await session.factory.run("argument-echo", { + args: { ordinal: 1 }, + notifyOnComplete: false, + }); + const second = await session.factory.run("argument-echo", { + args: { ordinal: 2 }, + notifyOnComplete: false, + }); + const third = await session.factory.run("argument-echo", { + args: { ordinal: 3 }, + notifyOnComplete: false, + }); + + const newest = await session.factory.listRuns({ limit: 1 }); + expect(newest).toMatchObject({ + runs: [expect.objectContaining({ runId: third.runId })], + hasMoreNewer: false, + omittedOlder: 2, + }); + expect(newest.oldestSeq).toBe(newest.newestSeq); + expect(newest.oldestSeq).not.toBeNull(); + + const older = await session.factory.listRuns({ + beforeSeq: newest.oldestSeq!, + limit: 1, + }); + expect(older).toMatchObject({ + runs: [expect.objectContaining({ runId: second.runId })], + hasMoreNewer: true, + omittedOlder: 1, + }); + + const oldest = await session.factory.listRuns({ + beforeSeq: older.oldestSeq!, + limit: 1, + }); + expect(oldest).toMatchObject({ + runs: [expect.objectContaining({ runId: first.runId })], + hasMoreNewer: true, + omittedOlder: 0, + }); +}); + it.skipIf(isInProcessTransport)( "runs a factory when its session denies every permission request", async () => { @@ -158,7 +254,9 @@ it.skipIf(isInProcessTransport)( const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); await using session = await setupFactoryExtension(workDir, denyPermissions); - await expect(session.factory.run("argument-echo")).resolves.toMatchObject({ + await expect( + session.factory.run("argument-echo", { notifyOnComplete: false }) + ).resolves.toMatchObject({ status: "completed", }); expect(denyPermissions).not.toHaveBeenCalled(); @@ -175,12 +273,17 @@ it.skipIf(isInProcessTransport)( const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); await using session = await setupFactoryExtension(workDir, denyPermissions); - const failedRun = await session.factory.run("fails-once"); + const failedRun = await session.factory.run("fails-once", { notifyOnComplete: false }); expect(failedRun).toMatchObject({ status: "error", }); - await expect(session.factory.resume(failedRun.runId)).resolves.toMatchObject({ + await expect( + session.factory.resume(failedRun.runId, { + notifyOnComplete: false, + logPhaseNames: false, + }) + ).resolves.toMatchObject({ status: "completed", result: "resumed", }); @@ -197,7 +300,9 @@ it.skipIf(isInProcessTransport)( const { workDir } = factoryTestContext; await using session = await setupFactoryExtension(workDir); - const result = await session.factory.run("starts-from-context-session"); + const result = await session.factory.run("starts-from-context-session", { + notifyOnComplete: false, + }); expect(result).toMatchObject({ status: "completed", @@ -216,7 +321,9 @@ it.skipIf(isInProcessTransport)( const { workDir } = factoryTestContext; await using session = await setupFactoryExtension(workDir); - const result = await session.factory.run("starts-from-module-session"); + const result = await session.factory.run("starts-from-module-session", { + notifyOnComplete: false, + }); expect(result).toMatchObject({ status: "completed", @@ -236,7 +343,7 @@ it.skipIf(isInProcessTransport)( const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); await using session = await setupFactoryExtension(workDir); - const parked = session.factory.run("parked"); + const parked = session.factory.run("parked", { notifyOnComplete: false }); await retry( "wait for the parked factory to enter its body", async () => { @@ -282,7 +389,7 @@ it.skipIf(isInProcessTransport)( const { workDir } = factoryTestContext; await using session = await setupFactoryExtension(workDir); - const result = await session.factory.run("array-result"); + const result = await session.factory.run("array-result", { notifyOnComplete: false }); expect(result).toMatchObject({ status: "completed", @@ -301,7 +408,10 @@ it.skipIf(isInProcessTransport)( await using session = await setupFactoryExtension(workDir); const args = [1, "two", false]; - const result = await session.factory.run("argument-echo", { args }); + const result = await session.factory.run("argument-echo", { + args, + notifyOnComplete: false, + }); expect(result).toMatchObject({ status: "completed", diff --git a/nodejs/test/e2e/fixtures/factory-extension.mjs b/nodejs/test/e2e/fixtures/factory-extension.mjs index 45227a1bea..fb344b4863 100644 --- a/nodejs/test/e2e/fixtures/factory-extension.mjs +++ b/nodejs/test/e2e/fixtures/factory-extension.mjs @@ -40,6 +40,21 @@ const arrayResult = defineFactory({ run: async () => [1, "two", false], }); +const phased = defineFactory({ + meta: { + name: "phased", + description: "Record named phases and ordinary progress.", + phases: [{ title: "Collect" }, { title: "Summarize" }], + }, + run: async ({ phase, log }) => { + phase("Collect"); + log("Collected"); + phase("Summarize"); + log("Summarized"); + return "finished"; + }, +}); + const forwardsSubagentOptions = defineFactory({ meta: { name: "forwards-subagent-options", @@ -141,6 +156,7 @@ session = await joinSession({ factories: [ argumentEcho, arrayResult, + phased, forwardsSubagentOptions, startsFromContextSession, startsFromModuleSession, @@ -153,6 +169,7 @@ void waitForMarker("start-b", 30_000) .then(async () => { const result = await session.factory.run("argument-echo", { args: { source: "module-watcher" }, + notifyOnComplete: false, }); writeFileSync(marker("b-result"), JSON.stringify({ status: "success", result })); }) diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index dcf434616a..c9b8f65074 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -435,6 +435,7 @@ describe("factories", () => { const guide = readFileSync(new URL("../docs/factories.md", import.meta.url), "utf8"); const publicApi = readFileSync(new URL("../src/factory.ts", import.meta.url), "utf8"); const listRunsPagingWording = "newest default page of this session's durable factory runs"; + const listRunsMetadata = ["oldestSeq", "newestSeq", "hasMoreNewer", "omittedOlder"]; const resumeCodes = [ "not_found", "non_resumable", @@ -458,6 +459,9 @@ describe("factories", () => { for (const document of [normalizedGuide, normalizedPublicApi]) { expect(document).toContain(listRunsPagingWording); + for (const field of listRunsMetadata) { + expect(document).toContain(field); + } } expect(normalizedGuide).toContain( @@ -1500,14 +1504,31 @@ describe("factories", () => { revision: 4, }; const detail = { ...summary, phases: [], agents: [], progress }; + const runsPage = { + runs: [summary], + oldestSeq: 11, + newestSeq: 12, + hasMoreNewer: true, + omittedOlder: 10, + }; const sendRequest = vi.fn(async (method: string) => { - if (method === "session.factory.listRuns") return { runs: [summary] }; + if (method === "session.factory.listRuns") return runsPage; if (method === "session.factory.getRunDetail") return detail; return progress; }); const session = new CopilotSession("session-observe", { sendRequest } as never); await expect(session.factory.listRuns()).resolves.toEqual([summary]); + const listedPage = await session.factory.listRuns({ + afterSeq: 10, + beforeSeq: 20, + limit: 50, + }); + expect(listedPage).toEqual(runsPage); + expect(listedPage.oldestSeq).toBe(11); + expect(listedPage.newestSeq).toBe(12); + expect(listedPage.hasMoreNewer).toBe(true); + expect(listedPage.omittedOlder).toBe(10); await expect(session.factory.getRunDetail("run-observe")).resolves.toEqual(detail); await expect( session.factory.getRunProgress("run-observe", { @@ -1519,11 +1540,17 @@ describe("factories", () => { expect(sendRequest).toHaveBeenNthCalledWith(1, "session.factory.listRuns", { sessionId: session.sessionId, }); - expect(sendRequest).toHaveBeenNthCalledWith(2, "session.factory.getRunDetail", { + expect(sendRequest).toHaveBeenNthCalledWith(2, "session.factory.listRuns", { + sessionId: session.sessionId, + afterSeq: 10, + beforeSeq: 20, + limit: 50, + }); + expect(sendRequest).toHaveBeenNthCalledWith(3, "session.factory.getRunDetail", { sessionId: session.sessionId, runId: "run-observe", }); - expect(sendRequest).toHaveBeenNthCalledWith(3, "session.factory.getRunProgress", { + expect(sendRequest).toHaveBeenNthCalledWith(4, "session.factory.getRunProgress", { sessionId: session.sessionId, runId: "run-observe", phaseId: "p0", @@ -2132,6 +2159,8 @@ describe("factories", () => { await expect( session.factory.resume("run-prior", { limits: { maxTotalSubagents: 7 }, + notifyOnComplete: true, + logPhaseNames: true, }) ).resolves.toMatchObject({ status: "completed", @@ -2141,13 +2170,20 @@ describe("factories", () => { session.factory.run("by-name", { args: { value: 1 }, limits: { maxTotalSubagents: 7 }, + notifyOnComplete: false, + logPhaseNames: true, resumeFromRunId: "run-prior", }) ).resolves.toMatchObject({ status: "completed", result: { name: "stored-name", persistedArgs: true }, }); - await expect(session.factory.run(factory)).resolves.toMatchObject({ + await expect( + session.factory.run(factory, { + notifyOnComplete: true, + logPhaseNames: false, + }) + ).resolves.toMatchObject({ status: "completed", result: { name: "friendly-run" }, }); @@ -2155,17 +2191,25 @@ describe("factories", () => { sessionId: session.sessionId, runId: "run-prior", limits: { maxTotalSubagents: 7 }, + notifyOnComplete: true, + logPhaseNames: true, }); expect(sendRequest).toHaveBeenNthCalledWith(2, "session.factory.resume", { sessionId: session.sessionId, runId: "run-prior", limits: { maxTotalSubagents: 7 }, + notifyOnComplete: false, + logPhaseNames: true, }); expect(sendRequest).toHaveBeenNthCalledWith(3, "session.factory.run", { sessionId: session.sessionId, name: "friendly-run", args: {}, - options: { limits: undefined }, + options: { + limits: undefined, + notifyOnComplete: true, + logPhaseNames: false, + }, }); }); From 7a916f8af8fb11315e9d043156487f9076264145 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 31 Aug 2026 20:18:29 +0000 Subject: [PATCH 07/21] [SDK] Expose Ask User Variant Session Option (#2432) * [SDK] Expose Ask User Variant Session Option Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Skip tool metadata check in-process The in-process transport does not expose current tool metadata for introspection, so keep the runtime schema assertion on the stdio cells where that RPC is supported. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Clarify legacy ask-user handler documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + dotnet/README.md | 7 +- dotnet/src/Client.cs | 4 + dotnet/src/Types.cs | 31 +++++++- .../test/Unit/ClientSessionLifetimeTests.cs | 48 +++++++++++ dotnet/test/Unit/CloneTests.cs | 2 + go/README.md | 6 +- go/client.go | 15 ++++ go/client_test.go | 79 +++++++++++++++++++ go/types.go | 26 +++++- java/README.md | 5 ++ .../github/copilot/SessionRequestBuilder.java | 2 + .../github/copilot/rpc/AskUserVariant.java | 59 ++++++++++++++ .../copilot/rpc/CreateSessionRequest.java | 13 +++ .../copilot/rpc/ResumeSessionConfig.java | 27 +++++++ .../copilot/rpc/ResumeSessionRequest.java | 13 +++ .../com/github/copilot/rpc/SessionConfig.java | 30 ++++++- .../com/github/copilot/ConfigCloneTest.java | 5 ++ .../copilot/SessionRequestBuilderTest.java | 37 +++++++++ nodejs/README.md | 9 ++- nodejs/src/client.ts | 2 + nodejs/src/index.ts | 1 + nodejs/src/types.ts | 17 +++- nodejs/test/client.test.ts | 33 ++++++++ nodejs/test/e2e/ui_elicitation.e2e.test.ts | 24 ++++++ python/README.md | 5 +- python/copilot/__init__.py | 2 + python/copilot/client.py | 20 +++++ python/copilot/session.py | 2 +- python/test_client.py | 55 +++++++++++++ rust/README.md | 6 ++ rust/src/handler.rs | 7 +- rust/src/types.rs | 54 +++++++++++-- rust/src/wire.rs | 14 ++-- rust/tests/session_test.rs | 58 +++++++++++++- 35 files changed, 692 insertions(+), 30 deletions(-) create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/AskUserVariant.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 345308849c..0184a553db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ The experimental Node.js Agent Factories convenience API now supports paginated Factory `run` and `resume` options now accept `notifyOnComplete` and `logPhaseNames`. The SDK forwards these options to the Copilot CLI for new and resumed runs. +### Feature: selectable `ask_user` session behavior + +Session create and cold resume now accept a language-specific `askUserVariant` option with `legacy` and `elicitation` values. SDK sessions retain the legacy question-and-answer tool by default. Select `elicitation` and provide an elicitation handler to expose the structured form-based `ask_user` tool. + ### Feature: rotating session-scoped GitHub credentials All six SDKs can now acquire short-lived GitHub credentials through a session-scoped callback. The SDK registers the callback before session create or resume, maps `initial` and `refresh` requests to the owning session, and removes registrations on rollback, replacement, session close, and client close. Static per-session `gitHubToken` credentials remain supported and are mutually exclusive with the callback. diff --git a/dotnet/README.md b/dotnet/README.md index 461ff0cf94..a6a419644f 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -135,7 +135,8 @@ Create a new conversation session. - `EnableSessionStore` - Enables the cross-session store for search and retrieval across sessions. When unset in `CopilotClientMode.CopilotCli`, the runtime default applies (enabled). In `CopilotClientMode.Empty`, defaults to disabled. - `GitHubTokenProvider` - Acquires session-scoped GitHub tokens on demand. Return `GitHubTokenProviderResult.FromToken` with a positive `ExpiresIn` value (production GitHub tokens typically use `8 * 60 * 60` seconds), or `GitHubTokenProviderResult.Cancel()`. Cannot be combined with `GitHubToken`. - `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.ApproveAll` approves requests when managed settings are disabled and throws when `EnableManagedSettings` is true. Custom handlers can inspect `ManagedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `OnUserInputRequest` - Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. +- `OnUserInputRequest` - Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `AskUserVariant` - Selects the model-facing `ask_user` tool shape. Defaults to `AskUserVariant.Legacy`; use `AskUserVariant.Elicitation` with `OnElicitationRequest`. - `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. ##### `ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null): Task` @@ -146,6 +147,7 @@ Resume an existing session. Returns the session with `WorkspacePath` populated i - `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. See [Permission Handling](#permission-handling) section. - `GitHubTokenProvider` - Replaces the session-scoped token provider when resuming. Cannot be combined with `GitHubToken`. +- `AskUserVariant` - Re-supplies the model-facing `ask_user` tool shape on cold resume. ```csharp await using var session = await client.CreateSessionAsync(new SessionConfig @@ -871,7 +873,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `SkipPe ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `OnUserInputRequest` handler: ```csharp var session = await client.CreateSessionAsync(new SessionConfig @@ -1004,6 +1006,7 @@ var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-5", OnPermissionRequest = PermissionHandler.ApproveAll, + AskUserVariant = AskUserVariant.Elicitation, OnElicitationRequest = async (context) => { // context.SessionId - Session that triggered the request diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f2da0a48f7..157e519811 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1190,6 +1190,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.EnableCitations, config.EnableFileChangeTracking, wireSystemMessage, + config.AskUserVariant, toolFilter.AvailableTools, toolFilter.ExcludedTools, config.ExcludedBuiltInAgents, @@ -1428,6 +1429,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.EnableCitations, config.EnableFileChangeTracking, wireSystemMessage, + config.AskUserVariant, toolFilter.AvailableTools, toolFilter.ExcludedTools, config.ExcludedBuiltInAgents, @@ -2868,6 +2870,7 @@ internal record CreateSessionRequest( bool? EnableCitations, bool? EnableFileChangeTracking, SystemMessageConfig? SystemMessage, + AskUserVariant? AskUserVariant, IList? AvailableTools, IList? ExcludedTools, [property: JsonPropertyName("excludedBuiltinAgents")] IList? ExcludedBuiltInAgents, @@ -2983,6 +2986,7 @@ internal record ResumeSessionRequest( bool? EnableCitations, bool? EnableFileChangeTracking, SystemMessageConfig? SystemMessage, + AskUserVariant? AskUserVariant, IList? AvailableTools, IList? ExcludedTools, [property: JsonPropertyName("excludedBuiltinAgents")] IList? ExcludedBuiltInAgents, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6ed05e3064..6cf02d2a73 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3116,6 +3116,21 @@ public sealed class ManagedSettings public ManagedSettingsPermissions? Permissions { get; set; } } +/// +/// Selects the model-facing shape of the built-in ask_user tool. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AskUserVariant +{ + /// Use the legacy user-input request flow. + [JsonStringEnumMemberName("legacy")] + Legacy, + + /// Use the elicitation request flow. + [JsonStringEnumMemberName("elicitation")] + Elicitation +} + /// /// Shared configuration properties for creating or resuming a Copilot session. /// Use when creating a new session, or @@ -3205,6 +3220,7 @@ protected SessionConfigBase(SessionConfigBase? other) ReasoningEffort = other.ReasoningEffort; ReasoningSummary = other.ReasoningSummary; ContextTier = other.ContextTier; + AskUserVariant = other.AskUserVariant; CreateSessionFsProvider = other.CreateSessionFsProvider; GitHubToken = other.GitHubToken; GitHubTokenProvider = other.GitHubTokenProvider; @@ -3372,6 +3388,15 @@ protected SessionConfigBase(SessionConfigBase? other) /// System message configuration for the session. public SystemMessageConfig? SystemMessage { get; set; } + /// + /// Selects the model-facing shape of the built-in ask_user tool. + /// The default is . To use + /// , also provide + /// so the host can answer structured forms. + /// The runtime resolves this option when it creates or cold-resumes the session. + /// + public AskUserVariant? AskUserVariant { get; set; } + /// List of tool names to allow; only these tools will be available when specified. public IList? AvailableTools { get; set; } @@ -3470,7 +3495,11 @@ protected SessionConfigBase(SessionConfigBase? other) /// Handler for permission requests from the server. public Func>? OnPermissionRequest { get; set; } - /// Handler for user input requests from the agent. + /// + /// Handler for user input requests from the agent. When provided with the default + /// variant, enables the + /// question-and-answer form of the ask_user tool. + /// public Func>? OnUserInputRequest { get; set; } /// Slash commands registered for this session. diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index b61546c650..bb0042efdb 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -464,6 +464,54 @@ public async Task CreateSessionAsync_Omits_CustomAgent_ReasoningEffort_When_Unse Assert.False(agent.TryGetProperty("reasoningEffort", out _)); } + [Fact] + public async Task CreateSessionAsync_Forwards_AskUserVariant() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + await using var session = await client.CreateSessionAsync(new SessionConfig + { + AskUserVariant = AskUserVariant.Elicitation, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + var request = Assert.Single(server.Requests, request => request.Method == "session.create"); + Assert.Equal("elicitation", request.Params.GetProperty("askUserVariant").GetString()); + + server.ClearRequests(); + await using var defaultSession = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + var defaultRequest = Assert.Single(server.Requests, request => request.Method == "session.create"); + Assert.False(defaultRequest.Params.TryGetProperty("askUserVariant", out _)); + } + + [Fact] + public async Task ResumeSessionAsync_Forwards_AskUserVariant_On_Cold_Resume() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + await using var session = await client.ResumeSessionAsync("ask-user-variant", new ResumeSessionConfig + { + AskUserVariant = AskUserVariant.Legacy, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + var request = Assert.Single(server.Requests, request => request.Method == "session.resume"); + Assert.Equal("legacy", request.Params.GetProperty("askUserVariant").GetString()); + + server.ClearRequests(); + await using var defaultSession = await client.ResumeSessionAsync("ask-user-variant-default", new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + var defaultRequest = Assert.Single(server.Requests, request => request.Method == "session.resume"); + Assert.False(defaultRequest.Params.TryGetProperty("askUserVariant", out _)); + } + [Fact] public async Task SessionRequests_Serialize_AdditionalDirectories() { diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 4bacdfe33d..2948ac2129 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -73,6 +73,7 @@ public void SessionConfig_Clone_CopiesAllProperties() ReasoningEffort = "high", ReasoningSummary = ReasoningSummary.Detailed, ContextTier = ContextTier.LongContext, + AskUserVariant = AskUserVariant.Elicitation, ConfigDirectory = "/config", AvailableTools = ["tool1", "tool2"], ExcludedTools = ["tool3"], @@ -121,6 +122,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.ReasoningEffort, clone.ReasoningEffort); Assert.Equal(original.ReasoningSummary, clone.ReasoningSummary); Assert.Equal(original.ContextTier, clone.ContextTier); + Assert.Equal(original.AskUserVariant, clone.AskUserVariant); Assert.Equal(original.ConfigDirectory, clone.ConfigDirectory); Assert.Equal(original.AvailableTools, clone.AvailableTools); Assert.Equal(original.ExcludedTools, clone.ExcludedTools); diff --git a/go/README.md b/go/README.md index ddd74b91aa..3d8706cec1 100644 --- a/go/README.md +++ b/go/README.md @@ -224,7 +224,8 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `EnableSessionStore` (\*bool): Enables the cross-session store for search and retrieval across sessions. When unset in `ModeCopilotCli`, the runtime default applies (enabled). In `ModeEmpty`, defaults to disabled. - `GitHubTokenProvider` (GitHubTokenProvider): Acquires session-scoped GitHub tokens on demand. Return `GitHubTokenResult` with a positive `ExpiresIn` value (production GitHub tokens typically use `8 * 60 * 60` seconds), or `GitHubTokenCancelled`. Cannot be combined with `GitHubToken`. - `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. `copilot.PermissionHandler.ApproveAll` approves requests when managed settings are disabled and returns an error when `EnableManagedSettings` is true. Custom handlers can inspect `RequiresManagedApproval()` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `OnUserInputRequest` (UserInputHandler): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. +- `OnUserInputRequest` (UserInputHandler): Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `AskUserVariant` (AskUserVariant): Selects the model-facing shape of the `ask_user` tool. The zero value preserves legacy behavior; use `AskUserVariantElicitation` with `OnElicitationRequest`. - `Hooks` (\*SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. - `Commands` ([]CommandDefinition): Slash-commands registered for this session. See [Commands](#commands) section. - `OnElicitationRequest` (ElicitationHandler): Handler for elicitation requests from the server. See [Elicitation Requests](#elicitation-requests-serverclient) section. @@ -238,6 +239,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Streaming` (*bool): Enable streaming delta events (nil = runtime default) - `Commands` ([]CommandDefinition): Slash-commands. See [Commands](#commands) section. - `OnElicitationRequest` (ElicitationHandler): Elicitation handler. See [Elicitation Requests](#elicitation-requests-serverclient) section. +- `AskUserVariant` (AskUserVariant): Selects the model-facing shape of the `ask_user` tool on cold resume. Re-supply `AskUserVariantElicitation` with `OnElicitationRequest`; the zero value preserves legacy behavior. - `GitHubTokenProvider` (GitHubTokenProvider): Replaces the session-scoped token provider when resuming. Cannot be combined with `GitHubToken`. ```go @@ -770,7 +772,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `SkipPe ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `OnUserInputRequest` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `OnUserInputRequest` handler: ```go session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ diff --git a/go/client.go b/go/client.go index 4e44696a55..2dbd7f7e95 100644 --- a/go/client.go +++ b/go/client.go @@ -788,6 +788,13 @@ func hasManagedSettings(enableManagedSettings *bool, managedSettings *ManagedSet return (enableManagedSettings != nil && *enableManagedSettings) || managedSettings != nil } +func validateAskUserVariant(variant AskUserVariant) error { + if variant != "" && variant != AskUserVariantLegacy && variant != AskUserVariantElicitation { + return fmt.Errorf("invalid AskUserVariant %q: expected %q, %q, or unset", variant, AskUserVariantLegacy, AskUserVariantElicitation) + } + return nil +} + func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { if config == nil { config = &SessionConfig{} @@ -795,6 +802,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.GitHubToken != "" && config.GitHubTokenProvider != nil { return nil, fmt.Errorf("GitHubToken and GitHubTokenProvider cannot be used together") } + if err := validateAskUserVariant(config.AskUserVariant); err != nil { + return nil, err + } if err := c.ensureConnected(ctx); err != nil { return nil, err @@ -842,6 +852,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Capi = config.Capi req.Providers = config.Providers req.Models = config.Models + req.AskUserVariant = config.AskUserVariant req.EnableSessionTelemetry = config.EnableSessionTelemetry req.EnableCitations = config.EnableCitations req.EnableFileChangeTracking = config.EnableFileChangeTracking @@ -1170,6 +1181,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.GitHubToken != "" && config.GitHubTokenProvider != nil { return nil, fmt.Errorf("GitHubToken and GitHubTokenProvider cannot be used together") } + if err := validateAskUserVariant(config.AskUserVariant); err != nil { + return nil, err + } if err := c.ensureConnected(ctx); err != nil { return nil, err @@ -1200,6 +1214,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.Capi = config.Capi req.Providers = config.Providers req.Models = config.Models + req.AskUserVariant = config.AskUserVariant req.EnableSessionTelemetry = config.EnableSessionTelemetry req.IsExperimentalMode = config.EnableExperimentalMode req.SkipCustomInstructions = config.SkipCustomInstructions diff --git a/go/client_test.go b/go/client_test.go index c6ab0808cb..b701de437f 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -445,6 +445,85 @@ func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { assertCapiEnableWebSocketResponses(t, <-resumeParams) } +func TestClient_ForwardsAskUserVariantToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } + + createParams := make(chan json.RawMessage, 2) + 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 + }) + resumeParams := make(chan json.RawMessage, 2) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + if _, err := client.CreateSession(t.Context(), &SessionConfig{ + SessionID: "ask-user-create", + AskUserVariant: AskUserVariantElicitation, + }); err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + if _, err := client.ResumeSession(t.Context(), "ask-user-cold-resume", &ResumeSessionConfig{ + AskUserVariant: AskUserVariantLegacy, + }); err != nil { + t.Fatalf("ResumeSession failed: %v", err) + } + if _, err := client.CreateSession(t.Context(), &SessionConfig{SessionID: "ask-user-default-create"}); err != nil { + t.Fatalf("CreateSession with default failed: %v", err) + } + if _, err := client.ResumeSession(t.Context(), "ask-user-default-cold-resume", nil); err != nil { + t.Fatalf("ResumeSession with default failed: %v", err) + } + + assertAskUserVariant(t, <-createParams, "elicitation") + assertAskUserVariant(t, <-resumeParams, "legacy") + assertAskUserVariant(t, <-createParams, "") + assertAskUserVariant(t, <-resumeParams, "") +} + +func TestClient_RejectsInvalidAskUserVariant(t *testing.T) { + client := &Client{} + + if _, err := client.CreateSession(t.Context(), &SessionConfig{ + AskUserVariant: AskUserVariant("unknown"), + }); err == nil || !strings.Contains(err.Error(), "AskUserVariant") { + t.Fatalf("CreateSession error = %v, want invalid AskUserVariant error", err) + } + if _, err := client.ResumeSession(t.Context(), "cold-resume", &ResumeSessionConfig{ + AskUserVariant: AskUserVariant("unknown"), + }); err == nil || !strings.Contains(err.Error(), "AskUserVariant") { + t.Fatalf("ResumeSession error = %v, want invalid AskUserVariant error", err) + } +} + +func assertAskUserVariant(t *testing.T, params json.RawMessage, want string) { + t.Helper() + var payload map[string]any + if err := json.Unmarshal(params, &payload); err != nil { + t.Fatalf("failed to decode request params: %v", err) + } + got, present := payload["askUserVariant"] + if want == "" { + if present { + t.Fatalf("askUserVariant = %v, want omitted", got) + } + return + } + if got != want { + t.Fatalf("askUserVariant = %v, want %q", got, want) + } +} + func TestClient_ForwardsAdditionalDirectoriesToSessionRequests(t *testing.T) { rpcClient, server, _ := newRuntimeShutdownRpcPair(t) t.Cleanup(server.Stop) diff --git a/go/types.go b/go/types.go index 1d98e06158..a574168aba 100644 --- a/go/types.go +++ b/go/types.go @@ -1251,6 +1251,16 @@ type GitHubMCPToolConfig struct { DisableFormDeferral *bool `json:"disableFormDeferral,omitempty"` } +// AskUserVariant selects the model-facing shape of the ask_user tool. +type AskUserVariant string + +const ( + // AskUserVariantLegacy uses the legacy user-input request implementation. + AskUserVariantLegacy AskUserVariant = "legacy" + // AskUserVariantElicitation uses the elicitation-based implementation. + AskUserVariantElicitation AskUserVariant = "elicitation" +) + // SessionConfig configures a new session type SessionConfig struct { // SessionID is an optional custom session ID @@ -1340,8 +1350,13 @@ type SessionConfig struct { // GitHubTokenProvider acquires session-scoped GitHub tokens on demand. It // cannot be combined with GitHubToken. GitHubTokenProvider GitHubTokenProvider - // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) + // OnUserInputRequest handles legacy question-and-answer requests from the agent + // and enables the legacy ask_user tool. OnUserInputRequest UserInputHandler + // AskUserVariant selects the model-facing shape of the ask_user tool. + // The zero value preserves legacy behavior. AskUserVariantElicitation also + // requires OnElicitationRequest so the host can answer structured forms. + AskUserVariant AskUserVariant // Hooks configures hook handlers for session lifecycle events Hooks *SessionHooks // WorkingDirectory is the working directory for the session. @@ -1914,8 +1929,13 @@ type ResumeSessionConfig struct { // OnMCPAuthRequest is an optional handler for MCP OAuth requests from MCP servers. // See SessionConfig.OnMCPAuthRequest. OnMCPAuthRequest MCPAuthHandler - // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) + // OnUserInputRequest handles legacy question-and-answer requests from the agent + // and enables the legacy ask_user tool. OnUserInputRequest UserInputHandler + // AskUserVariant selects the model-facing shape of the ask_user tool. + // The zero value preserves legacy behavior. AskUserVariantElicitation also + // requires OnElicitationRequest so the host can answer structured forms. + AskUserVariant AskUserVariant // Hooks configures hook handlers for session lifecycle events Hooks *SessionHooks // WorkingDirectory is the working directory for the session. @@ -2509,6 +2529,7 @@ type createSessionRequest struct { ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` + AskUserVariant AskUserVariant `json:"askUserVariant,omitempty"` RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` Hooks *bool `json:"hooks,omitempty"` @@ -2606,6 +2627,7 @@ type resumeSessionRequest struct { ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` + AskUserVariant AskUserVariant `json:"askUserVariant,omitempty"` RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` Hooks *bool `json:"hooks,omitempty"` diff --git a/java/README.md b/java/README.md index 2e1ba51812..e64964255b 100644 --- a/java/README.md +++ b/java/README.md @@ -176,6 +176,11 @@ directly. `CopilotClientOptions.setCwd(...)` sets the runtime process working directory, which otherwise inherits the current process working directory. `SessionConfig.setWorkingDirectory(...)` sets the session working directory, which otherwise defaults to the runtime process working directory. +`SessionConfig.setAskUserVariant(AskUserVariant.ELICITATION)` selects the +structured form-based `ask_user` tool when an elicitation handler is also set. +The default is `AskUserVariant.LEGACY`. Re-supply the option and handler through +`ResumeSessionConfig` on a cold resume. + For rotating per-session GitHub credentials, use `SessionConfig.setGitHubTokenProvider(...)` (or the equivalent `ResumeSessionConfig` setter) instead of `setGitHubToken(...)`: diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index 4254c04ec4..e0a8655390 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -119,6 +119,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setReasoningEffort(config.getReasoningEffort()); request.setReasoningSummary(config.getReasoningSummary()); request.setContextTier(config.getContextTier()); + request.setAskUserVariant(config.getAskUserVariant()); request.setTools(config.getTools()); request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); @@ -255,6 +256,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setReasoningEffort(config.getReasoningEffort()); request.setReasoningSummary(config.getReasoningSummary()); request.setContextTier(config.getContextTier()); + request.setAskUserVariant(config.getAskUserVariant()); request.setTools(config.getTools()); request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/AskUserVariant.java b/java/sdk/src/main/java/com/github/copilot/rpc/AskUserVariant.java new file mode 100644 index 0000000000..17c6a1333f --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/AskUserVariant.java @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Selects how the built-in {@code ask_user} tool collects user input. + */ +public enum AskUserVariant { + + /** Uses the legacy question-and-answer experience. */ + LEGACY("legacy"), + + /** Uses structured elicitation to collect user input. */ + ELICITATION("elicitation"); + + private final String value; + + AskUserVariant(String value) { + this.value = value; + } + + /** + * Returns the wire-format value. + * + * @return the value used in JSON serialization + */ + @JsonValue + public String getValue() { + return value; + } + + /** + * Creates an {@code AskUserVariant} from its wire-format value. + * + * @param value + * the wire-format value + * @return the matching variant, or {@code null} when {@code value} is + * {@code null} + * @throws IllegalArgumentException + * if the value is not {@code legacy} or {@code elicitation} + */ + @JsonCreator + public static AskUserVariant fromValue(String value) { + if (value == null) { + return null; + } + for (AskUserVariant variant : values()) { + if (variant.value.equals(value)) { + return variant; + } + } + throw new IllegalArgumentException("Unknown AskUserVariant value: " + value); + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 403893987d..ef3531fa5f 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -45,6 +45,9 @@ public final class CreateSessionRequest { @JsonProperty("contextTier") private String contextTier; + @JsonProperty("askUserVariant") + private AskUserVariant askUserVariant; + @JsonProperty("tools") private List tools; @@ -309,6 +312,16 @@ public void setContextTier(String contextTier) { this.contextTier = contextTier; } + /** Gets the ask-user variant. @return the ask-user variant */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** Sets the ask-user variant. @param askUserVariant the ask-user variant */ + public void setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + } + /** Gets the tools. @return the tool definitions */ public List getTools() { return tools == null ? null : Collections.unmodifiableList(tools); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index a55c3454e7..d13b67d715 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -63,6 +63,7 @@ public class ResumeSessionConfig { private String reasoningEffort; private String reasoningSummary; private String contextTier; + private AskUserVariant askUserVariant; private ModelCapabilitiesOverride modelCapabilities; private PermissionHandler onPermissionRequest; private McpAuthHandler onMcpAuthRequest; @@ -793,6 +794,31 @@ public ResumeSessionConfig setContextTier(String contextTier) { return this; } + /** + * Gets the experience used by the built-in {@code ask_user} tool. + * + * @return the ask-user variant, or {@code null} to use the legacy experience + */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** + * Sets the model-facing shape of the built-in {@code ask_user} tool when the + * session is resumed by a new client. + *

+ * When unset, the option is omitted and the legacy shape is used. Set an + * elicitation handler when selecting {@link AskUserVariant#ELICITATION}. + * + * @param askUserVariant + * the ask-user variant + * @return this config instance for method chaining + */ + public ResumeSessionConfig setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + return this; + } + /** * Gets the permission request handler. * @@ -2052,6 +2078,7 @@ public ResumeSessionConfig clone() { copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; copy.contextTier = this.contextTier; + copy.askUserVariant = this.askUserVariant; copy.modelCapabilities = this.modelCapabilities; copy.onPermissionRequest = this.onPermissionRequest; copy.onUserInputRequest = this.onUserInputRequest; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 9b8e897fda..99e4090438 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -47,6 +47,9 @@ public final class ResumeSessionRequest { @JsonProperty("contextTier") private String contextTier; + @JsonProperty("askUserVariant") + private AskUserVariant askUserVariant; + @JsonProperty("tools") private List tools; @@ -311,6 +314,16 @@ public void setContextTier(String contextTier) { this.contextTier = contextTier; } + /** Gets the ask-user variant. @return the ask-user variant */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** Sets the ask-user variant. @param askUserVariant the ask-user variant */ + public void setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + } + /** Gets the tools. @return the tool definitions */ public List getTools() { return tools == null ? null : Collections.unmodifiableList(tools); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index 9f6ddb5efa..cc2a858f68 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -46,6 +46,7 @@ public class SessionConfig { private String reasoningEffort; private String reasoningSummary; private String contextTier; + private AskUserVariant askUserVariant; private List tools; private SystemMessageConfig systemMessage; private List availableTools; @@ -254,6 +255,30 @@ public SessionConfig setContextTier(String contextTier) { return this; } + /** + * Gets the experience used by the built-in {@code ask_user} tool. + * + * @return the ask-user variant, or {@code null} to use the legacy experience + */ + public AskUserVariant getAskUserVariant() { + return askUserVariant; + } + + /** + * Sets the model-facing shape of the built-in {@code ask_user} tool. + *

+ * When unset, the option is omitted and the legacy shape is used. Set an + * elicitation handler when selecting {@link AskUserVariant#ELICITATION}. + * + * @param askUserVariant + * the ask-user variant + * @return this config instance for method chaining + */ + public SessionConfig setAskUserVariant(AskUserVariant askUserVariant) { + this.askUserVariant = askUserVariant; + return this; + } + /** * Gets the custom tools for this session. * @@ -894,7 +919,9 @@ public UserInputHandler getOnUserInputRequest() { /** * Sets a handler for user input requests from the agent. *

- * When provided, enables the ask_user tool for the agent to request user input. + * When provided, enables the legacy question-and-answer form of the + * {@code ask_user} tool. Use an elicitation handler with + * {@link AskUserVariant#ELICITATION}. * * @param onUserInputRequest * the user input handler @@ -2173,6 +2200,7 @@ public SessionConfig clone() { copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; copy.contextTier = this.contextTier; + copy.askUserVariant = this.askUserVariant; copy.tools = this.tools != null ? new ArrayList<>(this.tools) : null; copy.systemMessage = this.systemMessage; copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; diff --git a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java index 4c5a3fbef0..2433e5f67a 100644 --- a/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -18,6 +18,7 @@ import com.github.copilot.generated.SessionEvent; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; +import com.github.copilot.rpc.AskUserVariant; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.DefaultAgentConfig; import com.github.copilot.rpc.ExitPlanModeResult; @@ -119,6 +120,7 @@ void sessionConfigCloneBasic() { original.setModel("gpt-4o"); original.setReasoningSummary("detailed"); original.setContextTier("long_context"); + original.setAskUserVariant(AskUserVariant.ELICITATION); original.setPluginDirectories(List.of("/plugins/a", "/plugins/b")); original.setDisabledMcpServers(List.of("local-files", "remote-github")); original.setLargeOutput( @@ -133,6 +135,7 @@ void sessionConfigCloneBasic() { assertEquals(original.getModel(), cloned.getModel()); assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); assertEquals(original.getContextTier(), cloned.getContextTier()); + assertEquals(original.getAskUserVariant(), cloned.getAskUserVariant()); assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); assertEquals(original.getDisabledMcpServers(), cloned.getDisabledMcpServers()); assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); @@ -198,6 +201,7 @@ void resumeSessionConfigCloneBasic() { original.setModel("o1"); original.setReasoningSummary("none"); original.setContextTier("long_context"); + original.setAskUserVariant(AskUserVariant.LEGACY); original.setPluginDirectories(List.of("/plugins/r")); original.setDisabledMcpServers(List.of("local-files-r")); original.setLargeOutput( @@ -210,6 +214,7 @@ void resumeSessionConfigCloneBasic() { assertEquals(original.getModel(), cloned.getModel()); assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); assertEquals(original.getContextTier(), cloned.getContextTier()); + assertEquals(original.getAskUserVariant(), cloned.getAskUserVariant()); assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); assertEquals(original.getDisabledMcpServers(), cloned.getDisabledMcpServers()); assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); diff --git a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 9d76d18ee2..5ea8c69274 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import com.github.copilot.generated.rpc.SessionLimitsConfig; +import com.github.copilot.rpc.AskUserVariant; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; import com.github.copilot.rpc.CloudSessionRepository; @@ -78,6 +79,42 @@ void testGitHubTokenProviderResultRedactsToken() { assertFalse(result.toString().contains("do-not-print")); } + @Test + void askUserVariantIsForwardedAndSerializedForCreateAndColdResume() throws Exception { + var createRequest = SessionRequestBuilder.buildCreateRequest( + new SessionConfig().setAskUserVariant(AskUserVariant.ELICITATION), "create-session"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("resume-session", + new ResumeSessionConfig().setAskUserVariant(AskUserVariant.LEGACY)); + var mapper = JsonRpcClient.getObjectMapper(); + + assertEquals(AskUserVariant.ELICITATION, createRequest.getAskUserVariant()); + assertEquals("elicitation", + mapper.readTree(mapper.writeValueAsBytes(createRequest)).path("askUserVariant").asText()); + assertEquals(AskUserVariant.LEGACY, resumeRequest.getAskUserVariant()); + assertEquals("legacy", + mapper.readTree(mapper.writeValueAsBytes(resumeRequest)).path("askUserVariant").asText()); + } + + @Test + void askUserVariantDefaultsToOmittedLegacyBehavior() throws Exception { + var mapper = JsonRpcClient.getObjectMapper(); + var createRequest = SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "create-session"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("resume-session", new ResumeSessionConfig()); + + assertNull(createRequest.getAskUserVariant()); + assertFalse(mapper.readTree(mapper.writeValueAsBytes(createRequest)).has("askUserVariant")); + assertNull(resumeRequest.getAskUserVariant()); + assertFalse(mapper.readTree(mapper.writeValueAsBytes(resumeRequest)).has("askUserVariant")); + } + + @Test + void askUserVariantAcceptsOnlySupportedWireValues() { + assertEquals(AskUserVariant.LEGACY, AskUserVariant.fromValue("legacy")); + assertEquals(AskUserVariant.ELICITATION, AskUserVariant.fromValue("elicitation")); + assertThrows(IllegalArgumentException.class, () -> AskUserVariant.fromValue("ELICITATION")); + assertThrows(IllegalArgumentException.class, () -> AskUserVariant.fromValue("unsupported")); + } + @Test void testBuildCreateRequestHooksNonNullButEmpty() { // Hooks object exists but hasHooks() returns false diff --git a/nodejs/README.md b/nodejs/README.md index 93f9c3fa6b..53e81c7aa4 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -140,7 +140,8 @@ Create a new conversation session. - `gitHubTokenProvider?: GitHubTokenProvider` - Acquires rotating, session-scoped GitHub tokens. Token results require a positive `expiresIn` value in seconds remaining when the callback completes; production tokens typically last eight hours. Cannot be combined with `gitHubToken`. - `provider?: ProviderConfig` - Custom API provider configuration (BYOK - Bring Your Own Key). See [Custom Providers](#custom-providers) section. - `onPermissionRequest?: PermissionHandler` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `approveAll` approves requests when managed settings are disabled and throws when `enableManagedSettings` is true. Custom handlers can inspect `managedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `onUserInputRequest?: UserInputHandler` - Handler for user input requests from the agent. Enables the `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `onUserInputRequest?: UserInputHandler` - Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `askUserVariant?: "legacy" | "elicitation"` - Selects the model-facing `ask_user` tool shape when creating or cold-resuming a session. Defaults to `"legacy"`; use `"elicitation"` with `onElicitationRequest`. - `onElicitationRequest?: ElicitationHandler` - Handler for elicitation requests dispatched by the server. Enables this client to present form-based UI dialogs on behalf of the agent or other session participants. See [Elicitation Requests](#elicitation-requests) section. - `hooks?: SessionHooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. @@ -959,7 +960,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `skipPe ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `onUserInputRequest` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `onUserInputRequest` handler: ```typescript const session = await client.createSession({ @@ -991,6 +992,7 @@ Register an `onElicitationRequest` handler to let your client act as an elicitat const session = await client.createSession({ model: "gpt-5", onPermissionRequest: approveAll, + askUserVariant: "elicitation", onElicitationRequest: async (context) => { // context.sessionId - Session that triggered the request // context.message - Description of what information is needed @@ -1012,6 +1014,9 @@ const session = await client.createSession({ console.log(session.capabilities.ui?.elicitation); // true ``` +Set `askUserVariant: "elicitation"` to expose the structured form as the model's +`ask_user` tool. Omit it to retain the legacy SDK behavior. + When `onElicitationRequest` is provided, the SDK sends `requestElicitation: true` during session create/resume, which enables `session.capabilities.ui.elicitation` on the session. In multi-client scenarios: diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9b853aa597..13bb9c904e 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1678,6 +1678,7 @@ export class CopilotClient { requestPermission: !!config.onPermissionRequest, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + askUserVariant: config.askUserVariant, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } @@ -1946,6 +1947,7 @@ export class CopilotClient { config.onPermissionRequest !== defaultJoinSessionPermissionHandler, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + askUserVariant: config.askUserVariant, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 1f4359ff8c..8b9095e0ba 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -53,6 +53,7 @@ export { // surface for those six identifiers is preserved unchanged. export type * from "./generated/session-events.js"; export type { + AskUserVariant, CommandContext, CommandDefinition, CommandHandler, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 616e15a467..e6f90490ed 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1265,7 +1265,7 @@ export const defaultJoinSessionPermissionHandler: PermissionHandler = // ============================================================================ /** - * Request for user input from the agent (enables ask_user tool) + * Legacy question-and-answer request from the `ask_user` tool. */ export interface UserInputRequest { /** @@ -2245,6 +2245,9 @@ export interface ManagedSettings { permissions?: ManagedSettingsPermissions; } +/** Selects the model-facing shape of the built-in `ask_user` tool. */ +export type AskUserVariant = "legacy" | "elicitation"; + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -2556,10 +2559,20 @@ export interface SessionConfigBase { /** * Handler for user input requests from the agent. - * When provided, enables the ask_user tool allowing the agent to ask questions. + * When provided with the default `legacy` {@link AskUserVariant}, enables the + * question-and-answer form of the `ask_user` tool. */ onUserInputRequest?: UserInputHandler; + /** + * Selects the model-facing shape of the built-in `ask_user` tool. + * + * The default is `"legacy"`. To use `"elicitation"`, also provide + * {@link onElicitationRequest} so the host can answer structured forms. + * The runtime resolves this option when it creates or cold-resumes the session. + */ + askUserVariant?: AskUserVariant; + /** * Handler for elicitation requests from the agent. * When provided, the server calls back to this client for form-based UI dialogs. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3ffda2fa71..5baff82cb4 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -303,6 +303,39 @@ describe("CopilotClient", () => { }); }); + it("forwards the ask-user variant on create and cold resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + 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 onElicitationRequest = async () => ({ action: "decline" as const }); + + const session = await client.createSession({ + askUserVariant: "elicitation", + onElicitationRequest, + }); + await client.resumeSession(session.sessionId, { + askUserVariant: "elicitation", + onElicitationRequest, + }); + + expect(spy.mock.calls.find(([method]) => method === "session.create")![1]).toMatchObject({ + askUserVariant: "elicitation", + requestElicitation: true, + }); + expect(spy.mock.calls.find(([method]) => method === "session.resume")![1]).toMatchObject({ + askUserVariant: "elicitation", + requestElicitation: true, + }); + }); + it("omits GitHub MCP tool config when unset", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index 2e85dd5af2..ad2adab109 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -38,6 +38,30 @@ describe("UI Elicitation Callback", async () => { } ); + // In-process sessions do not expose current tool metadata for introspection. + it.skipIf(isInProcessTransport)( + "session created with the elicitation ask-user variant exposes the structured tool", + { timeout: 60_000 }, + async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + askUserVariant: "elicitation", + onElicitationRequest: async () => ({ action: "accept", content: {} }), + }); + + await session.rpc.tools.initializeAndValidate(); + const { tools } = await session.rpc.tools.getCurrentMetadata(); + const askUserSchema = tools?.find((tool) => tool.name === "ask_user")?.input_schema as + | { properties?: Record } + | undefined; + + expect(askUserSchema?.properties).toHaveProperty("message"); + expect(askUserSchema?.properties).toHaveProperty("requestedSchema"); + expect(askUserSchema?.properties).not.toHaveProperty("question"); + await session.disconnect(); + } + ); + it( "session created without onElicitationRequest reports no elicitation capability", { timeout: 60_000 }, diff --git a/python/README.md b/python/README.md index 61608c16a0..c130f21c65 100644 --- a/python/README.md +++ b/python/README.md @@ -283,7 +283,8 @@ These are passed as keyword arguments to `create_session()`: - `enable_session_store` (bool): Enables the cross-session store for search and retrieval across sessions. When unset in `"copilot-cli"` mode, the runtime default applies (enabled). In `"empty"` mode, defaults to disabled. - `github_token_provider` (callable): Acquires rotating, session-scoped GitHub tokens. Token results require a positive `expiresIn` value in seconds remaining when the callback completes; production tokens typically last eight hours. Cannot be combined with `github_token`. - `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.approve_all` approves requests when managed settings are disabled and raises an error when `enable_managed_settings` is true. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. -- `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. +- `on_user_input_request` (callable): Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. +- `ask_user_variant` (`"legacy"` | `"elicitation"`): Selects the model-facing shape of the `ask_user` tool. Defaults to `"legacy"`; use `"elicitation"` with `on_elicitation_request`. Re-supply this option when cold-resuming a session. - `hooks` (SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. ```python @@ -899,7 +900,7 @@ To let a specific custom tool bypass the permission prompt entirely, set `skip_p ## User Input Requests -Enable the agent to ask questions to the user using the `ask_user` tool by providing an `on_user_input_request` handler: +Enable the legacy question-and-answer `ask_user` tool by providing an `on_user_input_request` handler: ```python async def handle_user_input(request, invocation): diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 608dacf253..4b3b9901ed 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -29,6 +29,7 @@ OpenCanvasInstance, ) from .client import ( + AskUserVariant, CapiSessionOptions, ChildProcessRuntimeConnection, CloudSessionOptions, @@ -229,6 +230,7 @@ "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", + "AskUserVariant", "BUILTIN_TOOLS_ISOLATED", "CanvasAction", "CanvasDeclaration", diff --git a/python/copilot/client.py b/python/copilot/client.py index 271fad626c..05412246de 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -177,6 +177,8 @@ class GitHubTokenCancelledResult(TypedDict): _ConnectionState = Literal["disconnected", "connecting", "connected", "error"] LogLevel = Literal["none", "error", "warning", "info", "debug", "all"] +AskUserVariant = Literal["legacy", "elicitation"] +"""Model-facing shape of the runtime's built-in ``ask_user`` tool.""" @dataclass @@ -2209,6 +2211,7 @@ async def create_session( available_tools: list[str] | ToolSet | None = None, excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, + ask_user_variant: AskUserVariant | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, additional_directories: list[str] | None = None, @@ -2311,6 +2314,10 @@ async def create_session( including custom tools registered via ``tools=``. Ignored if ``available_tools`` is set. on_user_input_request: Handler for user input requests. + ask_user_variant: Model-facing shape of the ``ask_user`` tool. + Accepted values are ``"legacy"`` and ``"elicitation"``. The + default is ``"legacy"``. To use ``"elicitation"``, also provide + ``on_elicitation_request`` so the host can answer structured forms. hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. @@ -2465,6 +2472,8 @@ async def create_session( raise ValueError("on_permission_request must be callable when provided.") if github_token is not None and github_token_provider is not None: raise ValueError("github_token and github_token_provider are mutually exclusive") + if ask_user_variant not in (None, "legacy", "elicitation"): + raise ValueError('ask_user_variant must be "legacy" or "elicitation"') if not self._client: await self.start() @@ -2552,6 +2561,8 @@ async def create_session( # Enable user input request callback if handler provided if on_user_input_request: payload["requestUserInput"] = True + if ask_user_variant is not None: + payload["askUserVariant"] = ask_user_variant # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) @@ -2970,6 +2981,7 @@ async def resume_session( available_tools: list[str] | ToolSet | None = None, excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, + ask_user_variant: AskUserVariant | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, additional_directories: list[str] | None = None, @@ -3073,6 +3085,10 @@ async def resume_session( including custom tools registered via ``tools=``. Ignored if ``available_tools`` is set. on_user_input_request: Handler for user input requests. + ask_user_variant: Model-facing shape of the ``ask_user`` tool. + Accepted values are ``"legacy"`` and ``"elicitation"``. The + default is ``"legacy"``. To use ``"elicitation"``, also provide + ``on_elicitation_request`` so the host can answer structured forms. hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. @@ -3226,6 +3242,8 @@ async def resume_session( raise ValueError("on_permission_request must be callable when provided.") if github_token is not None and github_token_provider is not None: raise ValueError("github_token and github_token_provider are mutually exclusive") + if ask_user_variant not in (None, "legacy", "elicitation"): + raise ValueError('ask_user_variant must be "legacy" or "elicitation"') if not self._client: await self.start() @@ -3341,6 +3359,8 @@ async def resume_session( if on_user_input_request: payload["requestUserInput"] = True + if ask_user_variant is not None: + payload["askUserVariant"] = ask_user_variant # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) diff --git a/python/copilot/session.py b/python/copilot/session.py index 78afdde139..3c6d3d54a4 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -504,7 +504,7 @@ class McpAuthContext(TypedDict): class UserInputRequest(TypedDict, total=False): - """Request for user input from the agent (enables ask_user tool)""" + """Legacy question-and-answer request from the ask_user tool.""" question: str choices: list[str] diff --git a/python/test_client.py b/python/test_client.py index a33f0ecd60..bfcee83db2 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -211,6 +211,61 @@ async def test_resume_session_allows_none_permission_handler(self): class TestCreateSessionConfig: + @pytest.mark.asyncio + async def test_ask_user_variant_forwarded_on_create_and_cold_resume(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured: list[tuple[str, dict]] = [] + + async def mock_request(method, params, **kwargs): + captured.append((method, params)) + result = {"sessionId": params["sessionId"], "workspacePath": None} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + + client._client.request = mock_request + await client.create_session( + session_id="ask-user-create", + ask_user_variant="elicitation", + ) + await client.resume_session( + "ask-user-cold-resume", + ask_user_variant="legacy", + ) + await client.create_session(session_id="ask-user-default-create") + await client.resume_session("ask-user-default-cold-resume") + + payloads = {(method, params["sessionId"]): params for method, params in captured} + assert ( + payloads[("session.create", "ask-user-create")]["askUserVariant"] == "elicitation" + ) + assert ( + payloads[("session.resume", "ask-user-cold-resume")]["askUserVariant"] == "legacy" + ) + assert "askUserVariant" not in payloads[("session.create", "ask-user-default-create")] + assert ( + "askUserVariant" not in payloads[("session.resume", "ask-user-default-cold-resume")] + ) + finally: + await client.force_stop() + + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create", "resume"]) + async def test_ask_user_variant_rejects_unknown_values(self, method): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + + with pytest.raises(ValueError, match="ask_user_variant"): + if method == "create": + await client.create_session(ask_user_variant="unknown") # type: ignore[arg-type] + else: + await client.resume_session( + "ask-user-cold-resume", + ask_user_variant="unknown", # type: ignore[arg-type] + ) + @pytest.mark.asyncio async def test_additional_directories_forwarded_on_create_and_resume(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/README.md b/rust/README.md index 323d525d37..6114105c25 100644 --- a/rust/README.md +++ b/rust/README.md @@ -274,6 +274,11 @@ let config = SessionConfig { let session = client.create_session(config).await?; ``` +Use `with_ask_user_variant(AskUserVariant::Elicitation)` together with +`with_elicitation_handler(...)` to expose the structured form-based `ask_user` +tool. The default remains `AskUserVariant::Legacy`. Re-supply the option and +handler through `ResumeSessionConfig` on a cold resume. + For rotating per-session GitHub credentials, install a `GitHubTokenProvider` instead of setting `github_token`: @@ -521,6 +526,7 @@ impl ElicitationHandler for MyElicitation { let config = SessionConfig::default() .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_ask_user_variant(AskUserVariant::Elicitation) .with_elicitation_handler(Arc::new(MyElicitation)); ``` diff --git a/rust/src/handler.rs b/rust/src/handler.rs index e036b75a10..61d9b192cb 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -294,10 +294,11 @@ pub trait McpAuthHandler: Send + Sync + 'static { ) -> McpAuthResult; } -/// Handler for `user_input.requested` events from the `ask_user` tool. +/// Handler for `user_input.requested` events from the legacy question-and-answer +/// `ask_user` variant. /// -/// When unset, `requestUserInput: false` goes on the wire and the -/// `ask_user` tool is disabled for the session. +/// When unset, `requestUserInput: false` goes on the wire, so this client +/// cannot handle legacy user-input requests. #[async_trait] pub trait UserInputHandler: Send + Sync + 'static { /// Answer a question on behalf of the user. Return `None` to signal diff --git a/rust/src/types.rs b/rust/src/types.rs index 6e451eb452..726bc48f4c 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1851,6 +1851,18 @@ impl ManagedSettings { } } +/// Selects the model-facing shape of the built-in `ask_user` tool. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum AskUserVariant { + /// Use the legacy user-input request flow. + #[default] + Legacy, + /// Use the elicitation request flow. + Elicitation, +} + /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -1924,6 +1936,11 @@ pub struct SessionConfig { pub streaming: Option, /// Custom system message configuration. pub system_message: Option, + /// Selects the model-facing shape of the built-in `ask_user` tool. + /// + /// When omitted, the runtime uses [`AskUserVariant::Legacy`]. To use + /// [`AskUserVariant::Elicitation`], also install an [`ElicitationHandler`]. + pub ask_user_variant: Option, /// Client-defined tool declarations to expose to the agent. pub tools: Option>, /// Canvas declarations this connection provides to the runtime. @@ -2181,9 +2198,9 @@ pub struct SessionConfig { /// Optional MCP OAuth request handler. When set, the SDK can satisfy MCP /// server OAuth requests with host-acquired token data or cancellation. pub mcp_auth_handler: Option>, - /// Optional user-input handler. When `None`, - /// `requestUserInput: false` goes on the wire and the `ask_user` - /// tool is disabled. + /// Optional handler for the legacy question-and-answer `ask_user` variant. + /// When `None`, `requestUserInput: false` goes on the wire, so this client + /// cannot handle legacy user-input requests. pub user_input_handler: Option>, /// Optional exit-plan-mode handler. When `None`, /// `requestExitPlanMode: false` goes on the wire. @@ -2238,6 +2255,7 @@ impl std::fmt::Debug for SessionConfig { .field("context_tier", &self.context_tier) .field("streaming", &self.streaming) .field("system_message", &self.system_message) + .field("ask_user_variant", &self.ask_user_variant) .field("tools", &self.tools) .field("canvases", &self.canvases) .field( @@ -2378,6 +2396,7 @@ impl Default for SessionConfig { context_tier: None, streaming: None, system_message: None, + ask_user_variant: None, tools: None, canvases: None, canvas_handler: None, @@ -2545,6 +2564,7 @@ impl SessionConfig { context_tier: self.context_tier, streaming: self.streaming, system_message: self.system_message, + ask_user_variant: self.ask_user_variant, tools: self.tools, canvases: wire_canvases, request_canvas_renderer: self.request_canvas_renderer, @@ -2656,13 +2676,19 @@ impl SessionConfig { self } - /// Install a [`UserInputHandler`]. Required for the `ask_user` tool - /// to be enabled. + /// Install a [`UserInputHandler`] for the legacy question-and-answer + /// `ask_user` variant. pub fn with_user_input_handler(mut self, handler: Arc) -> Self { self.user_input_handler = Some(handler); self } + /// Select the model-facing shape of the built-in `ask_user` tool. + pub fn with_ask_user_variant(mut self, variant: AskUserVariant) -> Self { + self.ask_user_variant = Some(variant); + self + } + /// Install an [`ExitPlanModeHandler`]. pub fn with_exit_plan_mode_handler(mut self, handler: Arc) -> Self { self.exit_plan_mode_handler = Some(handler); @@ -3304,6 +3330,11 @@ pub struct ResumeSessionConfig { /// Re-supply the system message so the agent retains workspace context /// across CLI process restarts. pub system_message: Option, + /// Selects the model-facing shape of the built-in `ask_user` tool on a cold resume. + /// + /// When omitted, the runtime uses [`AskUserVariant::Legacy`]. To use + /// [`AskUserVariant::Elicitation`], also install an [`ElicitationHandler`]. + pub ask_user_variant: Option, /// Client-defined tool declarations to re-supply on resume. pub tools: Option>, /// Canvas declarations this connection provides to the runtime. @@ -3547,6 +3578,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("context_tier", &self.context_tier) .field("streaming", &self.streaming) .field("system_message", &self.system_message) + .field("ask_user_variant", &self.ask_user_variant) .field("tools", &self.tools) .field("canvases", &self.canvases) .field( @@ -3730,6 +3762,7 @@ impl ResumeSessionConfig { context_tier: self.context_tier, streaming: self.streaming, system_message: self.system_message, + ask_user_variant: self.ask_user_variant, tools: self.tools, canvases: wire_canvases, open_canvases: self.open_canvases, @@ -3836,6 +3869,7 @@ impl ResumeSessionConfig { context_tier: None, streaming: None, system_message: None, + ask_user_variant: None, tools: None, canvases: None, canvas_handler: None, @@ -3939,6 +3973,12 @@ impl ResumeSessionConfig { self } + /// Select the model-facing shape of the built-in `ask_user` tool on resume. + pub fn with_ask_user_variant(mut self, variant: AskUserVariant) -> Self { + self.ask_user_variant = Some(variant); + self + } + /// Install an [`ExitPlanModeHandler`] for the resumed session. pub fn with_exit_plan_mode_handler(mut self, handler: Arc) -> Self { self.exit_plan_mode_handler = Some(handler); @@ -6239,6 +6279,8 @@ mod tests { assert!(!wire.request_auto_mode_switch); assert!(!wire.hooks); assert!(!wire.request_mcp_apps); + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("askUserVariant").is_none()); } #[test] @@ -6255,6 +6297,8 @@ mod tests { assert!(!wire.request_auto_mode_switch); assert!(!wire.hooks); assert!(!wire.request_mcp_apps); + let json = serde_json::to_value(&wire).unwrap(); + assert!(json.get("askUserVariant").is_none()); } #[test] diff --git a/rust/src/wire.rs b/rust/src/wire.rs index f7de33839c..ebc050477e 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -24,11 +24,11 @@ use crate::generated::api_types::{ }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ - CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, - DefaultAgentConfig, ExtensionInfo, GitHubMcpToolConfig, InfiniteSessionConfig, - LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, NamedProviderConfig, - ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, SystemMessageConfig, Tool, - ToolSearchConfig, + AskUserVariant, CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, + CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, GitHubMcpToolConfig, + InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, + NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, + SystemMessageConfig, Tool, ToolSearchConfig, }; /// Wire representation of a slash command (name + description only). The @@ -64,6 +64,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub system_message: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ask_user_variant: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub canvases: Option>, @@ -218,6 +220,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub system_message: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ask_user_variant: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub canvases: Option>, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index a51d61910d..fb16a0f674 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -24,8 +24,8 @@ use github_copilot_sdk::session_events::{ SessionManagedSettingsResolvedData, }; use github_copilot_sdk::types::{ - CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext, - CommandDefinition, CommandHandler, DeliveryMode, DisableBypassPermissionsModes, + AskUserVariant, CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, + CommandContext, CommandDefinition, CommandHandler, DeliveryMode, DisableBypassPermissionsModes, ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, ManagedSettings, ManagedSettingsPermissions, MessageOptions, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource, PermissionDecisionSurface, RequestId, @@ -918,6 +918,60 @@ async fn create_session_sends_new_session_options() { timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn create_session_forwards_ask_user_variant() { + 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().with_ask_user_variant(AskUserVariant::Elicitation), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["askUserVariant"], "elicitation"); + + let session_id = requested_session_id(&request).to_string(); + server_respond_create(&mut server_write, &request, &session_id).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn cold_resume_session_forwards_ask_user_variant() { + 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("ask-user-variant")) + .with_ask_user_variant(AskUserVariant::Legacy), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["sessionId"], "ask-user-variant"); + assert_eq!(request["params"]["askUserVariant"], "legacy"); + + server_respond_create(&mut server_write, &request, "ask-user-variant").await; + respond_to_reload(&mut server_read, &mut server_write).await; + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + #[tokio::test] async fn resume_session_sends_new_session_options() { use github_copilot_sdk::types::ResumeSessionConfig; From 128927eb6590394a1d8de5a646da07873e1523ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 08:30:49 +0000 Subject: [PATCH 08/21] docs: update version references to 1.0.13-preview.3 --- java/README.md | 8 ++++---- java/sdk/jbang-example.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/java/README.md b/java/README.md index e64964255b..fb4aeb9d25 100644 --- a/java/README.md +++ b/java/README.md @@ -32,14 +32,14 @@ Replace `${copilot.sdk.version}` with the latest release from Maven Central. com.github copilot-sdk-java - 1.0.13-preview.2 + 1.0.13-preview.3 ``` ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.13-preview.2' +implementation 'com.github:copilot-sdk-java:1.0.13-preview.3' ``` #### Snapshot Builds @@ -58,7 +58,7 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.14-preview.2-SNAPSHOT + 1.0.14-preview.3-SNAPSHOT ``` @@ -67,7 +67,7 @@ Snapshot builds of the next development version are published to Maven Central S Replace `${copilot.sdk.version}` with the latest release from Maven Central. ```groovy -implementation 'com.github:copilot-sdk-java:1.0.14-preview.2-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.14-preview.3-SNAPSHOT' ``` ## In-process mode (experimental) diff --git a/java/sdk/jbang-example.java b/java/sdk/jbang-example.java index 6cb04215ac..afff013803 100644 --- a/java/sdk/jbang-example.java +++ b/java/sdk/jbang-example.java @@ -1,5 +1,5 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS com.github:copilot-sdk-java:1.0.13-preview.2 +//DEPS com.github:copilot-sdk-java:1.0.13-preview.3 import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; From 07ae7a18c078514c32d22a9cc37a82b4d083c335 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 08:31:20 +0000 Subject: [PATCH 09/21] [maven-release-plugin] prepare release java/v1.0.13-preview.3 --- java/copilot-native/pom.xml | 4 ++-- java/pom.xml | 4 ++-- java/sdk/pom.xml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml index d138a0e56b..7466113a01 100644 --- a/java/copilot-native/pom.xml +++ b/java/copilot-native/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.2-SNAPSHOT + 1.0.13-preview.3 ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.3 diff --git a/java/pom.xml b/java/pom.xml index 427bc2320c..f7e5cbf53e 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.2-SNAPSHOT + 1.0.13-preview.3 pom GitHub Copilot SDK :: Java :: Parent @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.3 diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index 811a05db67..b9ec366282 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.2-SNAPSHOT + 1.0.13-preview.3 ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.3 From 6adcf00d433614d550563835b688012d5b9c5782 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 08:31:24 +0000 Subject: [PATCH 10/21] [maven-release-plugin] prepare for next development iteration --- java/copilot-native/pom.xml | 4 ++-- java/pom.xml | 4 ++-- java/sdk/pom.xml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml index 7466113a01..a0272acdc4 100644 --- a/java/copilot-native/pom.xml +++ b/java/copilot-native/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.3 + 1.0.14-preview.3-SNAPSHOT ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.3 + HEAD diff --git a/java/pom.xml b/java/pom.xml index f7e5cbf53e..bd317cd197 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.3 + 1.0.14-preview.3-SNAPSHOT pom GitHub Copilot SDK :: Java :: Parent @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.3 + HEAD diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index b9ec366282..46ae52e487 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.3 + 1.0.14-preview.3-SNAPSHOT ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.3 + HEAD From 538b2dce30755ca53f60b5ad42d11a98addf8edb Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Tue, 1 Sep 2026 08:25:14 +0000 Subject: [PATCH 11/21] Launch managed SDK servers through the Rust runtime wrapper (#2395) * Integrate out-of-process Rust runtime wrapper Stage and launch copilot-runtime beside runtime.node across all SDKs, with an opt-in local runtime-worktree override until published packages include the wrapper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Validate runtime wrapper across SDK harnesses Route out-of-process E2E harnesses through the local runtime override while preserving the residual CLI for in-process execution. Keep Java bundled residual injection intact and surface Python fast-exit diagnostics reliably. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Use residual CLI for Python FFI test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Honor per-client runtime environment in .NET Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Handle Rust session requests during creation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Skip Go telemetry callback test in-process Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Add legacy CLI launch escape hatch Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Remove residual Node runtime compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Remove COPILOT_RUNTIME_PATH override Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * fix(rust): materialize runtime launch contract Ensure managed wrapper launches materialize and hand off the compatible host, and expose the same launch descriptor for health checks and intermediate launchers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Remove residual runtime host contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2455380f-4747-4900-89ca-93b30399de03 * fix(rust): materialize sibling CLI host Extract the bundled CLI beside copilot-runtime and runtime.node so managed and intermediate launches retain compatibility through the wrapper's sibling fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * fix(rust): create runtime install directory Ensure a clean bundled runtime cache can publish runtime.node, the wrapper, and sibling CLI before any artifact path exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Complete managed runtime bundle materialization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Remove managed SEA staging for hostless runtime Keep the root CLI artifact bundled for direct and in-process use, while managed out-of-process caches materialize only copilot-runtime and runtime.node. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Stage auxiliary runtime assets from npm packages Preserve unknown package assets by default while filtering known CLI-only content in each SDK's existing staging path. Keep wrapper companions adjacent, migrate caches safely, and retain executable metadata for external runtime tools. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Exclude runtime package documentation from staging Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Finalize runtime wrapper integration after rebase Preserve the explicit .NET single-binary override, refresh stale Rust runtime bundles safely, and document the Python npm registry override. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Use executable cache for Node runtime wrapper Stage the managed wrapper under the platform user cache instead of the temporary directory so noexec temp mounts do not prevent startup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Temporarily skip extension-host E2E coverage Document PR #2395 and the Rust-only out-of-process transition on Node extension-authored factory coverage, the real-host extension environment test, and .NET extension lifecycle tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Address runtime wrapper review feedback Match the successful grep completion to the grep tool invocation and document Java's bundled runtime-wrapper default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Stop resolving SEA for in-process hosting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Keep default runtime bundles SEA-free Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Fix cross-platform runtime integration tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Fix runtime CI harness portability Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Relax closed-stream startup assertion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Fix failed-start runtime test handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Format Python runtime test setup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Clean up failed Node runtime startup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Fix Java token provider sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Fix runtime integration CI checks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Make Java relative path test drive-safe Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Use generated logging for permission failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Suppress Node pipe writes after runtime exit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Preserve Node startup transport errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * Add Rust extension launch provider Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Clarify extension launch environment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc29e35-611b-46d8-9b8b-e1912d2eccf4 * test(rust): cover runtime extension lifecycle Add a real-wrapper regression that exercises typed provider dispatch, extension registration, tool invocation, disable/re-enable, environment ownership, and process teardown. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * fix(java): honor runtime path environment override Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 * test(node): skip host-dependent ask user metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(node): preserve startup stderr after pipe errors Wait for the child close event before selecting startup diagnostics so early EPIPE failures do not hide the runtime's actionable stderr output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(node): separate exit and diagnostic waits Keep startup RPCs fail-fast on process exit while using child close only to collect fully drained stderr after an early pipe error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(node): preserve ask user metadata coverage Run the Node-hosted metadata assertion through the harness's explicit legacy CLI path instead of disabling it for managed hostless connections. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * refactor(node): simplify startup failure handling Keep startup teardown fail-fast and verify startup failures without waiting separately for diagnostic stream closure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * style(node): format startup failure test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * test(python): set plan mode before handler test Await the explicit mode transition before sending the replayed prompt so exit_plan_mode is deterministically available to the model. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * test: preserve live in-process runtime state Keep isolated runtime home files until the shared in-process client shuts down so later tests never operate on POSIX-unlinked state. Apply the same lifecycle rule to the Node and Python E2E harnesses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * test: stabilize transient runtime checks Observe Rust processing state concurrently with message dispatch so fast in-process turns cannot complete before polling starts. Exercise rewind through an edit of a pre-existing file across SDKs, avoiding the runtime's unreliable new-file capture path while preserving file restoration coverage on every platform. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * Stabilize Rust processing state E2E Hold a replayed custom tool invocation open while asserting the transient processing state, then release it and verify completion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * Stabilize Windows .NET E2E coverage Use explicit .NET Framework handling for nondeterministic stdio startup diagnostics and complete a warm-up turn before exercising rewind file tracking across SDKs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * test: stabilize rewind and Rust resume E2E Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 * fix: stabilize runtime disconnect races Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Stephen Toub Copilot-Session: 3f5c3c30-0cf7-4b9a-9a3b-2b01ca073015 Copilot-Session: 2455380f-4747-4900-89ca-93b30399de03 Copilot-Session: efc29e35-611b-46d8-9b8b-e1912d2eccf4 Copilot-Session: b90d19df-b3fa-4539-b921-8b301028da11 --- dotnet/README.md | 5 + dotnet/src/Client.cs | 160 ++++-- dotnet/src/FfiRuntimeHost.cs | 84 ++- dotnet/src/Session.cs | 5 +- dotnet/src/build/GitHub.Copilot.SDK.targets | 57 ++ dotnet/test/E2E/BuiltinToolsE2ETests.cs | 17 - dotnet/test/E2E/ClientE2ETests.cs | 18 +- dotnet/test/E2E/PendingWorkResumeE2ETests.cs | 23 + dotnet/test/E2E/RewindE2ETests.cs | 34 +- dotnet/test/Unit/MSBuildTargetsTests.cs | 77 +++ dotnet/test/Unit/RuntimeWrapperTests.cs | 134 +++++ go/README.md | 7 +- go/client.go | 42 +- go/client_test.go | 29 + go/cmd/bundler/main.go | 393 +++++++++++--- go/cmd/bundler/main_test.go | 126 ++++- go/inprocess_disabled.go | 2 +- go/inprocess_enabled.go | 4 +- go/internal/e2e/inprocess_ffi_e2e_test.go | 4 +- .../e2e/pending_work_resume_e2e_test.go | 25 + go/internal/e2e/rewind_e2e_test.go | 66 ++- go/internal/embeddedcli/embeddedcli.go | 292 +++++++++- go/internal/embeddedcli/embeddedcli_test.go | 136 +++++ go/internal/ffihost/ffihost.go | 45 +- go/internal/ffihost/ffihost_test.go | 45 +- go/internal/ffihost/resolve.go | 10 +- java/README.md | 16 +- java/copilot-native/pom.xml | 15 +- .../create-native-classifier-test-fixture.mjs | 8 +- java/copilot-native/scripts/fetch-native.mjs | 160 ++++-- .../scripts/fetch-native.test.mjs | 128 ++++- .../scripts/validate-native-artifact.mjs | 8 +- .../scripts/validate-native-artifact.test.mjs | 10 +- .../com/github/copilot/CliServerManager.java | 124 +++-- .../com/github/copilot/CopilotClient.java | 13 +- .../github/copilot/ffi/FfiRuntimeHost.java | 19 +- .../copilot/ffi/NativeRuntimeLoader.java | 151 +++++- .../copilot/rpc/CopilotClientOptions.java | 9 +- .../github/copilot/CliServerManagerTest.java | 45 ++ .../copilot/e2e/InProcessTransportIT.java | 13 +- .../copilot/e2e/OutOfProcessTransportIT.java | 38 ++ .../java/com/github/copilot/e2e/RewindIT.java | 42 +- .../copilot/ffi/FfiRuntimeHostTest.java | 41 ++ .../copilot/ffi/NativeRuntimeLoaderTest.java | 136 ++++- nodejs/README.md | 1 + nodejs/src/client.ts | 121 +++-- nodejs/src/ffiRuntimeHost.ts | 65 +-- nodejs/src/runtimeArtifacts.ts | 183 +++++++ nodejs/test/client.test.ts | 22 + nodejs/test/e2e/builtin_tools.e2e.test.ts | 14 + nodejs/test/e2e/client.e2e.test.ts | 18 +- .../test/e2e/extension_env_access.e2e.test.ts | 87 ++- nodejs/test/e2e/factory.e2e.test.ts | 508 ++++++++---------- nodejs/test/e2e/harness/sdkTestContext.ts | 32 +- nodejs/test/e2e/inprocess_ffi.e2e.test.ts | 5 +- .../test/e2e/pending_work_resume.e2e.test.ts | 42 +- nodejs/test/e2e/rewind.e2e.test.ts | 117 ++-- nodejs/test/e2e/ui_elicitation.e2e.test.ts | 40 +- nodejs/test/runtimeArtifacts.test.ts | 109 ++++ python/README.md | 24 +- python/copilot/_cli_download.py | 165 +++++- python/copilot/_ffi_runtime_host.py | 56 +- python/copilot/client.py | 82 +-- python/e2e/conftest.py | 11 + python/e2e/test_inprocess_ffi_e2e.py | 12 +- python/e2e/test_mode_handlers_e2e.py | 8 +- python/e2e/test_rewind_e2e.py | 31 +- python/e2e/testharness/context.py | 13 +- python/test_cli_download.py | 114 ++++ python/test_client.py | 21 + rust/Cargo.toml | 15 + rust/README.md | 138 +++-- rust/build.rs | 5 - rust/build/in_process.rs | 359 +++++++++---- rust/src/embeddedcli.rs | 329 ++++++++++-- rust/src/extension_launch_provider.rs | 158 ++++++ rust/src/ffi.rs | 128 ++--- rust/src/lib.rs | 176 +++++- rust/src/resolve.rs | 157 +++++- rust/src/router.rs | 7 + rust/src/startup_timings.rs | 2 +- rust/tests/cli_resolution_test.rs | 145 +++-- rust/tests/e2e/rewind.rs | 47 +- rust/tests/e2e/rpc_session_state.rs | 63 ++- rust/tests/e2e/session.rs | 5 +- rust/tests/e2e/support.rs | 11 +- .../extension_launch_provider_runtime_test.rs | 321 +++++++++++ rust/tests/extension_launch_provider_test.rs | 493 +++++++++++++++++ rust/tests/fixtures/extension_fixture.rs | 127 +++++ ...restore_tracked_file_and_conversation.yaml | 26 +- ...eport_processing_and_context_metadata.yaml | 12 +- 91 files changed, 5941 insertions(+), 1500 deletions(-) create mode 100644 dotnet/test/Unit/RuntimeWrapperTests.cs create mode 100644 java/sdk/src/test/java/com/github/copilot/e2e/OutOfProcessTransportIT.java create mode 100644 nodejs/src/runtimeArtifacts.ts create mode 100644 nodejs/test/runtimeArtifacts.test.ts create mode 100644 rust/src/extension_launch_provider.rs create mode 100644 rust/tests/extension_launch_provider_runtime_test.rs create mode 100644 rust/tests/extension_launch_provider_test.rs create mode 100644 rust/tests/fixtures/extension_fixture.rs diff --git a/dotnet/README.md b/dotnet/README.md index a6a419644f..23a78030b4 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -101,6 +101,11 @@ new CopilotClient(CopilotClientOptions? options = null) - `RuntimeConnection.ForTcp(port = 0, connectionToken?, path?, args?)` — spawns the runtime as a child process listening on a TCP port. `port = 0` auto-allocates; if a non-zero port is already in use, startup fails (no fallback). Use `CopilotClient.RuntimePort` after `StartAsync` to read the assigned port. `connectionToken` is required if other clients will connect via `RuntimeConnection.ForUri(...)`. - `RuntimeConnection.ForUri(url, connectionToken?)` — connects to an already-running runtime at `url` (e.g., `"localhost:8080"`). Does not spawn a process. +Managed stdio and TCP connections use the bundled `copilot-runtime[.exe]` and +adjacent `runtime.node` by default. An explicit connection path or +`COPILOT_CLI_PATH` overrides the bundled runtime. +Managed launch fails if the bundled wrapper pair is unavailable. + #### Methods ##### `StartAsync(): Task` diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 157e519811..092112cebc 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -55,6 +55,7 @@ namespace GitHub.Copilot; /// public sealed partial class CopilotClient : IDisposable, IAsyncDisposable { + private const string ExplicitBundledCliMarker = ".copilot-explicit-cli"; ///

/// Minimum protocol version this SDK can communicate with. /// @@ -416,9 +417,19 @@ async Task StartCoreAsync(CancellationToken ct) ffiArgs.Add("--remote"); } + var explicitCliPath = System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); + if (string.IsNullOrEmpty(explicitCliPath)) + { + explicitCliPath = null; + } + var ffiRuntimePath = explicitCliPath is null + ? GetBundledNativePath(FfiRuntimeHost.GetRuntimeLibraryFileName(), out var searchedRuntime) + ?? throw new InvalidOperationException( + $"In-process FFI runtime library not found at '{searchedRuntime}'.") + : ResolveRuntimePathForExplicitCli(explicitCliPath); var ffiHost = FfiRuntimeHost.Create( - ResolveCliPathForFfi(), - GetNapiPrebuildsFolderOrThrow(), + ffiRuntimePath, + explicitCliPath, ffiEnvironment, ffiArgs, _logger); @@ -493,6 +504,20 @@ await InvokeRpcAsync( await CleanupCliProcessAsync(cliProcess, stderrPump, errors: null, _logger); } + if (ex is IOException + && cliProcess is not null + && stderrPump is not null + && !ex.Message.Contains("stderr:", StringComparison.OrdinalIgnoreCase)) + { + var stderrOutput = GetStderrOutput(stderrPump.Buffer); + if (!string.IsNullOrEmpty(stderrOutput)) + { + throw new IOException( + FormatCliExitedMessage("CLI process exited unexpectedly.", stderrOutput), + ex); + } + } + throw; } } @@ -673,7 +698,7 @@ or IOException private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List? errors, ILogger? logger) { - stderrPump?.Cancel(); + var processExited = false; try { @@ -706,12 +731,19 @@ private static async Task CleanupCliProcessAsync(Process childProcess, ProcessSt AddCleanupError(errors, ex, logger); } } + + processExited = childProcess.HasExited; } catch (Exception ex) { AddCleanupError(errors, ex, logger); } + if (!processExited) + { + stderrPump?.Cancel(); + } + if (stderrPump is not null) { var stderrPumpWaitTimestamp = Stopwatch.GetTimestamp(); @@ -721,6 +753,7 @@ private static async Task CleanupCliProcessAsync(Process childProcess, ProcessSt } catch (TimeoutException ex) { + stderrPump.Cancel(); if (logger is not null) { LoggingHelpers.LogTiming(logger, LogLevel.Debug, ex, @@ -1960,13 +1993,15 @@ private static string FormatCliExitedMessage(string message, string stderrOutput private static IOException CreateCliExitedException(string message, StringBuilder stderrBuffer) { - string stderrOutput; + return new IOException(FormatCliExitedMessage(message, GetStderrOutput(stderrBuffer))); + } + + private static string GetStderrOutput(StringBuilder stderrBuffer) + { lock (stderrBuffer) { - stderrOutput = stderrBuffer.ToString().Trim(); + return stderrBuffer.ToString().Trim(); } - - return new IOException(FormatCliExitedMessage(message, stderrOutput)); } private Task EnsureConnectedAsync(CancellationToken cancellationToken) @@ -2217,17 +2252,19 @@ private static void ApplyTelemetryEnvironment(IDictionary envir var tcpConnection = _connection as TcpRuntimeConnection; var useStdio = _connection is StdioRuntimeConnection; - // Use explicit path, COPILOT_CLI_PATH env var (from the connection's - // Environment, options.Environment, or process env), or bundled runtime - no PATH fallback - var envCliPath = - (childProcessConnection.Environment is not null && childProcessConnection.Environment.TryGetValue("COPILOT_CLI_PATH", out var connEnvValue) ? connEnvValue : null) - ?? (options.Environment is not null && options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) ? envValue : null) - ?? System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); - var cliPath = childProcessConnection.Path - ?? envCliPath - ?? GetBundledCliPath(out var searchedPath) - ?? throw new InvalidOperationException($"Copilot runtime not found at '{searchedPath}'. Ensure the SDK NuGet package was restored correctly or provide an explicit RuntimeConnection.ForStdio(path: ...) / RuntimeConnection.ForTcp(path: ...)."); - var cliPathSource = childProcessConnection.Path is not null ? "Options" : envCliPath is not null ? "Environment" : "Bundled"; + // Explicit CLI paths preserve the legacy launch contract. Otherwise use + // the bundled native runtime pair. + var configuredEnvironment = childProcessConnection.Environment ?? options.Environment; + var envCliPath = configuredEnvironment is not null + ? configuredEnvironment.TryGetValue("COPILOT_CLI_PATH", out var configuredCliPath) ? configuredCliPath : null + : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); + var launch = childProcessConnection.Path is not null + ? new RuntimeLaunch(childProcessConnection.Path, "Options") + : envCliPath is not null + ? new RuntimeLaunch(envCliPath, "Environment") + : GetBundledRuntimeLaunch(); + var cliPath = launch.Executable; + var cliPathSource = launch.Source; var args = new List(); if (childProcessConnection.Args != null) @@ -2409,7 +2446,11 @@ private static void ApplyTelemetryEnvironment(IDictionary envir private static string? GetBundledCliPath(out string searchedPath) { - var binaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot"; + return GetBundledNativePath(OperatingSystem.IsWindows() ? "copilot.exe" : "copilot", out searchedPath); + } + + private static string? GetBundledNativePath(string binaryName, out string searchedPath) + { // Always use portable RID (e.g., linux-x64) to match the build-time placement, // since distro-specific RIDs (e.g., ubuntu.24.04-x64) are normalized at build time. var rid = GetPortableRid() @@ -2418,6 +2459,57 @@ private static void ApplyTelemetryEnvironment(IDictionary envir return File.Exists(searchedPath) ? searchedPath : null; } + private static RuntimeLaunch GetBundledRuntimeLaunch() + { + _ = GetBundledNativePath( + OperatingSystem.IsWindows() ? "copilot-runtime.exe" : "copilot-runtime", + out var searchedWrapper); + var directory = Path.GetDirectoryName(searchedWrapper)!; + var runtimeNode = Path.Combine(directory, "runtime.node"); + var explicitCliMarker = Path.Combine(directory, ExplicitBundledCliMarker); + if (!File.Exists(searchedWrapper) + && !File.Exists(runtimeNode) + && File.Exists(explicitCliMarker) + && GetBundledCliPath(out _) is { } explicitCli) + { + return new RuntimeLaunch(explicitCli, "Bundled explicit CLI"); + } + return ValidateRuntimePair(searchedWrapper, "Bundled runtime"); + } + + private static RuntimeLaunch ValidateRuntimePair(string wrapper, string source) + { + var runtimeNode = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(wrapper))!, "runtime.node"); + if (!File.Exists(wrapper)) + { + throw new InvalidOperationException($"Copilot runtime wrapper not found at '{wrapper}'."); + } + if (!File.Exists(runtimeNode)) + { + throw new InvalidOperationException( + $"Copilot runtime wrapper at '{wrapper}' is missing its adjacent runtime.node at '{runtimeNode}'."); + } + if (new FileInfo(wrapper).Length == 0 || new FileInfo(runtimeNode).Length == 0) + { + throw new InvalidOperationException("Copilot runtime wrapper and adjacent runtime.node must both be non-empty."); + } +#if NET8_0_OR_GREATER + if (!OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(wrapper); + const UnixFileMode executeBits = + UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + if ((mode & executeBits) == 0) + { + File.SetUnixFileMode(wrapper, mode | executeBits); + } + } +#endif + return new RuntimeLaunch(wrapper, source); + } + + private sealed record RuntimeLaunch(string Executable, string Source); + private static string? GetPortableRid() { string os; @@ -2441,26 +2533,22 @@ private static void ApplyTelemetryEnvironment(IDictionary envir return arch != null ? $"{os}-{arch}" : null; } - private string ResolveCliPathForFfi() + private static string ResolveRuntimePathForExplicitCli(string cliPath) { - var envCliPath = _options.Environment is not null && _options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) - ? envValue - : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); - if (!string.IsNullOrEmpty(envCliPath)) + var fullEntrypoint = Path.GetFullPath(cliPath); + var directory = Path.GetDirectoryName(fullEntrypoint) + ?? throw new InvalidOperationException($"Could not determine directory for '{cliPath}'."); + var flatLibraryPath = Path.Combine(directory, FfiRuntimeHost.GetRuntimeLibraryFileName()); + if (File.Exists(flatLibraryPath)) { - return envCliPath; + return flatLibraryPath; } - - // Fall back to the bundled single-file CLI the same way stdio discovers it. - // It embeds its own Node and is spawned directly as `copilot --embedded-host`, - // with the sibling cdylib loaded in-process (FfiRuntimeHost.Create prefers the - // flat `libcopilot_runtime.so`/`copilot_runtime.dll` next to the CLI, falling - // back to the dev `prebuilds//runtime.node` layout). - var bundled = GetBundledCliPath(out var searchedPath); - return bundled - ?? throw new InvalidOperationException( - "In-process FFI hosting requires the Copilot CLI. Set the COPILOT_CLI_PATH " - + $"environment variable, or ensure the bundled CLI is present (looked in '{searchedPath}')."); + var prebuildsLibraryPath = Path.Combine( + directory, "prebuilds", GetNapiPrebuildsFolderOrThrow(), "runtime.node"); + return File.Exists(prebuildsLibraryPath) + ? prebuildsLibraryPath + : throw new InvalidOperationException( + $"FFI runtime library not found. Looked for '{flatLibraryPath}' and '{prebuildsLibraryPath}'."); } /// diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs index a838b9fd17..cc3bccae92 100644 --- a/dotnet/src/FfiRuntimeHost.cs +++ b/dotnet/src/FfiRuntimeHost.cs @@ -17,11 +17,9 @@ namespace GitHub.Copilot; /// and communicating over stdio/TCP. /// /// -/// The Rust host_start export spawns the residual TypeScript worker itself — -/// typically the packaged single-file CLI (copilot --embedded-host, which embeds -/// its own Node) or, for dev, node dist-cli/index.js --embedded-host — so the .NET -/// host never launches Node directly. JSON-RPC frames are pumped across the ABI: writes go -/// to connection_write; inbound frames arrive on a native callback that feeds +/// The Rust host_start export constructs the server synchronously in this +/// process. JSON-RPC frames are pumped across the ABI: writes go to +/// connection_write; inbound frames arrive on a native callback that feeds /// . /// /// The native interop layer has two implementations selected by target framework. On @@ -41,7 +39,7 @@ internal sealed partial class FfiRuntimeHost : IDisposable private const string LibraryName = "copilot_runtime"; private readonly ILogger _logger; - private readonly string _cliEntrypoint; + private readonly string? _cliEntrypoint; private readonly string _libraryPath; private readonly IReadOnlyDictionary? _environment; private readonly IReadOnlyList _args; @@ -53,7 +51,7 @@ internal sealed partial class FfiRuntimeHost : IDisposable private uint _connectionId; private bool _disposed; - private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger) + private FfiRuntimeHost(string libraryPath, string? cliEntrypoint, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger) { _libraryPath = libraryPath; _cliEntrypoint = cliEntrypoint; @@ -70,35 +68,22 @@ private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictio ?? throw new InvalidOperationException("FfiRuntimeHost has not been started."); /// - /// Loads the cdylib next to the given CLI entrypoint and prepares the FFI host. - /// The entrypoint is either the packaged single-file CLI binary (e.g. - /// runtimes/<rid>/native/copilot) or, for dev, a .js file (e.g. - /// dist-cli/index.js) launched via node. The cdylib is resolved - /// relative to the entrypoint directory, preferring the flat, natural - /// shared-library name the .NET build emits (e.g. libcopilot_runtime.so) - /// and falling back to the dev tarball layout - /// prebuilds/<prebuildsFolder>/runtime.node, where - /// is the napi-rs - /// <node-platform>-<arch> folder name (e.g. win32-x64). + /// Loads the runtime cdylib and prepares the FFI host. /// - public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger) + public static FfiRuntimeHost Create(string libraryPath, string? cliEntrypoint, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger) { - var fullEntrypoint = Path.GetFullPath(cliEntrypoint); - var distDir = Path.GetDirectoryName(fullEntrypoint) - ?? throw new InvalidOperationException($"Could not determine directory for '{cliEntrypoint}'."); - - // Bundled .NET layout: flat, natural shared-library name next to the CLI. - var flatLibraryPath = Path.Combine(distDir, GetRuntimeLibraryFileName()); - // Dev/tarball layout: dist-cli/prebuilds/-/runtime.node. - var prebuildsLibraryPath = Path.Combine(distDir, "prebuilds", prebuildsFolder, "runtime.node"); - - var libraryPath = File.Exists(flatLibraryPath) ? flatLibraryPath - : File.Exists(prebuildsLibraryPath) ? prebuildsLibraryPath - : throw new InvalidOperationException( - $"FFI runtime library not found. Looked for '{flatLibraryPath}' and '{prebuildsLibraryPath}'."); - - PrepareNativeLibrary(libraryPath); - return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args, logger); + var fullLibraryPath = Path.GetFullPath(libraryPath); + if (!File.Exists(fullLibraryPath)) + { + throw new InvalidOperationException($"FFI runtime library not found at '{fullLibraryPath}'."); + } + PrepareNativeLibrary(fullLibraryPath); + return new FfiRuntimeHost( + fullLibraryPath, + cliEntrypoint is null ? null : Path.GetFullPath(cliEntrypoint), + environment, + args, + logger); } /// @@ -106,7 +91,7 @@ public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder /// emitted by the .NET build (the .node file renamed to what the Rust cdylib /// would be called on this OS). /// - private static string GetRuntimeLibraryFileName() + internal static string GetRuntimeLibraryFileName() { if (OperatingSystem.IsWindows()) return "copilot_runtime.dll"; if (OperatingSystem.IsMacOS()) return "libcopilot_runtime.dylib"; @@ -114,14 +99,11 @@ private static string GetRuntimeLibraryFileName() } /// - /// Starts the in-process runtime: spawns the CLI worker via the Rust host, - /// waits for readiness, and opens the FFI JSON-RPC connection. + /// Starts the in-process Rust runtime and opens the FFI JSON-RPC connection. /// public async Task StartAsync(CancellationToken cancellationToken) { - // host_start blocks until the worker connects back and signals readiness - // (up to ~30s), and connection_open must run outside any async runtime, so - // perform the blocking FFI handshake on a background thread. + // Keep synchronous native startup off the caller's async context. await Task.Run(() => { var argvJson = BuildArgvJson(_cliEntrypoint, _args); @@ -131,7 +113,7 @@ await Task.Run(() => if (_serverId == 0) { throw new InvalidOperationException( - $"copilot_runtime_host_start failed (library '{_libraryPath}', entrypoint '{_cliEntrypoint}')."); + $"copilot_runtime_host_start failed (library '{_libraryPath}')."); } _connectionId = NativeOpenConnection(_serverId); @@ -154,24 +136,22 @@ await Task.Run(() => } } - private static byte[] BuildArgvJson(string cliEntrypoint, IReadOnlyList args) + private static byte[] BuildArgvJson(string? cliEntrypoint, IReadOnlyList args) { - // A .js entrypoint (dev / dist-cli) is launched via node; the packaged - // single-file CLI binary embeds its own Node and is invoked directly. - var isJsFile = cliEntrypoint.EndsWith(".js", StringComparison.OrdinalIgnoreCase); using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) { writer.WriteStartArray(); - if (isJsFile) + if (cliEntrypoint is not null) { - writer.WriteStringValue("node"); + if (cliEntrypoint.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) + { + writer.WriteStringValue("node"); + } + writer.WriteStringValue(cliEntrypoint); + writer.WriteStringValue("--embedded-host"); + writer.WriteStringValue("--no-auto-update"); } - writer.WriteStringValue(cliEntrypoint); - writer.WriteStringValue("--embedded-host"); - // Pin the worker to the bundled pkg matching the loaded cdylib, instead of - // drifting to a newer version under the user's ~/.copilot/pkg (ABI skew). - writer.WriteStringValue("--no-auto-update"); foreach (var arg in args) { writer.WriteStringValue(arg); diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 7d076d14b0..5995abaaff 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -977,7 +977,7 @@ private async Task ExecutePermissionAndRespondAsync(string requestId, Permission } catch (Exception ex) { - _logger.LogError(ex, "Permission handler or response delivery failed. SessionId={SessionId}, RequestId={RequestId}", SessionId, requestId); + LogPermissionHandlerOrDeliveryFailed(ex, SessionId, requestId); try { await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, PermissionDecision.UserNotAvailable()); @@ -1975,6 +1975,9 @@ await InvokeRpcAsync( [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to fetch tool metadata for {toolName}")] private partial void LogToolMetadataFetchFailed(Exception exception, string toolName); + [LoggerMessage(Level = LogLevel.Error, Message = "Permission handler or response delivery failed. SessionId={SessionId}, RequestId={RequestId}")] + private partial void LogPermissionHandlerOrDeliveryFailed(Exception exception, string sessionId, string requestId); + internal record SendMessageRequest { public string SessionId { get; init; } = string.Empty; diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index 5f7944b2c4..95770dba8e 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -38,6 +38,8 @@ <_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-arm64'">darwin-arm64 <_CopilotBinary Condition="$(_CopilotRid.StartsWith('win-'))">copilot.exe <_CopilotBinary Condition="'$(_CopilotBinary)' == ''">copilot + <_CopilotRuntimeWrapper Condition="$(_CopilotRid.StartsWith('win-'))">copilot-runtime.exe + <_CopilotRuntimeWrapper Condition="'$(_CopilotRuntimeWrapper)' == ''">copilot-runtime <_CopilotRuntimeNodePath>$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\runtime.node + <_CopilotRuntimeWrapperPath Condition="'$(_CopilotRuntimeWrapperPath)' == ''">$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\$(_CopilotRuntimeWrapper) + <_CopilotRuntimeAssetManifest>$(_CopilotOutputDir)\.copilot-runtime-assets + <_CopilotExplicitCliMarker>$(_CopilotOutputDir)\.copilot-explicit-cli + + + + + + <_CopilotSafePreviousRuntimeAsset Include="@(_CopilotPreviousRuntimeAsset)" + Condition="!$([System.IO.Path]::IsPathRooted('%(Identity)')) And !$([System.String]::Copy('%(Identity)').Contains('..'))" /> + + + + + <_CopilotRuntimeRootAsset Include="$(_CopilotCacheDir)\**\*" + Exclude="$(_CopilotCacheDir)\app.js;$(_CopilotCacheDir)\assets\**\*;$(_CopilotCacheDir)\changelog.json;$(_CopilotCacheDir)\copilot;$(_CopilotCacheDir)\copilot.exe;$(_CopilotCacheDir)\copilot-sdk\**\*;$(_CopilotCacheDir)\copilot.tgz;$(_CopilotCacheDir)\foundry-local-sdk\**\*;$(_CopilotCacheDir)\index.js;$(_CopilotCacheDir)\LICENSE.md;$(_CopilotCacheDir)\napi-oop-runtime\**\*;$(_CopilotCacheDir)\npm-loader.js;$(_CopilotCacheDir)\package.json;$(_CopilotCacheDir)\prebuilds\**\*;$(_CopilotCacheDir)\preloads\**\*;$(_CopilotCacheDir)\pvrecorder\**\*;$(_CopilotCacheDir)\queries\**\*;$(_CopilotCacheDir)\README.md;$(_CopilotCacheDir)\sdk\**\*;$(_CopilotCacheDir)\sea-loader.js;$(_CopilotCacheDir)\tree-sitter*.wasm;$(_CopilotCacheDir)\voice-*.js;$(_CopilotCacheDir)\webview\**\*" /> + <_CopilotRuntimePrebuildAsset Include="$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\**\*" + Exclude="$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\cli-native.node;$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\mediaremote-adapter\**\*;$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\copilot-runtime-bin*" /> + + + + + + + + + diff --git a/dotnet/test/E2E/BuiltinToolsE2ETests.cs b/dotnet/test/E2E/BuiltinToolsE2ETests.cs index 37067ecc60..143487dac4 100644 --- a/dotnet/test/E2E/BuiltinToolsE2ETests.cs +++ b/dotnet/test/E2E/BuiltinToolsE2ETests.cs @@ -112,21 +112,9 @@ public async Task Should_Create_A_New_File() Assert.Contains("Created by test", msg?.Data.Content ?? string.Empty); } - // TODO(cli-1.0.81-2): the grep and glob built-in tools shell out to the CLI's - // bundled ripgrep, which the runtime cannot locate when it is loaded in-process - // over FFI ("Failed to execute ripgrep: No such file or directory"). The tool - // then returns an error the recorded snapshots do not cover. Re-enable once the - // in-process runtime resolves its bundled binaries. - private static bool RipgrepUnavailable => E2ETestContext.UsesInProcessTransport; - [Fact] public async Task Should_Search_For_Patterns_In_Files() { - if (RipgrepUnavailable) - { - return; - } - await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "data.txt"), "apple\nbanana\napricot\ncherry\n"); var session = await CreateSessionAsync(); var msg = await session.SendAndWaitAsync(new MessageOptions @@ -141,11 +129,6 @@ public async Task Should_Search_For_Patterns_In_Files() [Fact] public async Task Should_Find_Files_By_Pattern() { - if (RipgrepUnavailable) - { - return; - } - Directory.CreateDirectory(Path.Join(Ctx.WorkDir, "src")); await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "src", "index.ts"), "export const index = 1;"); await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "README.md"), "# Readme"); diff --git a/dotnet/test/E2E/ClientE2ETests.cs b/dotnet/test/E2E/ClientE2ETests.cs index b6bdfd90fd..282cc9ee67 100644 --- a/dotnet/test/E2E/ClientE2ETests.cs +++ b/dotnet/test/E2E/ClientE2ETests.cs @@ -41,9 +41,8 @@ public async Task Should_Start_And_Connect_To_Server(bool useStdio) [Fact] public async Task Should_Start_And_Connect_Over_InProcess_Ffi() { - // In-process FFI hosting resolves the CLI entrypoint (COPILOT_CLI_PATH or the - // bundled CLI binary) and its sibling native runtime library itself; if neither - // is available, StartAsync throws and the test fails hard. + // In-process FFI hosting loads the bundled runtime library directly; if it is + // unavailable, StartAsync throws and the test fails hard. using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForInProcess(), @@ -193,15 +192,18 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start(bool u var ex = await Assert.ThrowsAsync(() => client.StartAsync()); var errorMessage = ex.Message; - // On .NET Framework with stdio transport, the pipe error may not include stderr content. - if (errorMessage.Contains("pipe", StringComparison.OrdinalIgnoreCase)) +#if NETFRAMEWORK + // .NET Framework can surface the stdio pipe failure before redirected stderr is readable. + if (useStdio) { - // .NET Framework pipe behavior — just verify we got an IOException - Assert.Contains("pipe", errorMessage, StringComparison.OrdinalIgnoreCase); + Assert.True( + errorMessage.Contains("CLI", StringComparison.OrdinalIgnoreCase) + || errorMessage.Contains("pipe", StringComparison.OrdinalIgnoreCase), + $"Expected a CLI process or pipe error, got: {errorMessage}"); } else +#endif { - // Verify we get the stderr output in the error message Assert.Contains("stderr", errorMessage, StringComparison.OrdinalIgnoreCase); Assert.Contains("nonexistent", errorMessage, StringComparison.OrdinalIgnoreCase); } diff --git a/dotnet/test/E2E/PendingWorkResumeE2ETests.cs b/dotnet/test/E2E/PendingWorkResumeE2ETests.cs index b3ca218190..bf1ed687c1 100644 --- a/dotnet/test/E2E/PendingWorkResumeE2ETests.cs +++ b/dotnet/test/E2E/PendingWorkResumeE2ETests.cs @@ -196,7 +196,30 @@ await session1.SendAsync(new MessageOptions if (disconnectOriginalClient) { + await using var lockObserver = Ctx.CreateClient(options: new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(), + }); + await lockObserver.StartAsync(); + await TestHelper.WaitForConditionAsync( + async () => + { + var result = await lockObserver.Rpc.Sessions.CheckInUseAsync([sessionId]); + return result.InUse.Contains(sessionId); + }, + timeout: PendingWorkTimeout, + timeoutMessage: $"Timed out waiting for session '{sessionId}' to acquire its lock."); + await suspendedClient.ForceStopAsync(); + + await TestHelper.WaitForConditionAsync( + async () => + { + var result = await lockObserver.Rpc.Sessions.CheckInUseAsync([sessionId]); + return !result.InUse.Contains(sessionId); + }, + timeout: PendingWorkTimeout, + timeoutMessage: $"Timed out waiting for session '{sessionId}' to release its lock."); } await using var resumedClient = Ctx.CreateClient(options: new CopilotClientOptions { Connection = RuntimeConnection.ForUri(cliUrl, connectionToken: SharedToken) }); diff --git a/dotnet/test/E2E/RewindE2ETests.cs b/dotnet/test/E2E/RewindE2ETests.cs index 74c0b8ab9f..3152b2e908 100644 --- a/dotnet/test/E2E/RewindE2ETests.cs +++ b/dotnet/test/E2E/RewindE2ETests.cs @@ -13,26 +13,37 @@ public class RewindE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "rewind", output) { private const string FileName = "rewind-sdk.txt"; + private const string OriginalFileContent = "Original rewind content"; + private const string PreparedFileContent = "Prepared rewind content"; private const string FileContent = "SDK rewind content"; [Fact] public async Task Should_Restore_Tracked_File_And_Conversation() { - // TODO(cli-1.0.81): Re-enable when Windows file-change tracking records built-in create tool writes. - if (OperatingSystem.IsWindows()) - return; - var filePath = Path.Join(Ctx.WorkDir, FileName); + await File.WriteAllTextAsync(filePath, OriginalFileContent); await using var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5", EnableFileChangeTracking = true, }); + var ready = await session.SendAndWaitAsync( + new MessageOptions + { + Prompt = $"Use the edit tool to replace the exact contents of {FileName} " + + $"from {OriginalFileContent} to {PreparedFileContent}. " + + "After the tool succeeds, reply with exactly SDK_REWIND_READY.", + }, + TimeSpan.FromSeconds(30)); + Assert.Equal("SDK_REWIND_READY", ready?.Data.Content); + Assert.Equal(PreparedFileContent, await File.ReadAllTextAsync(filePath)); + var response = await session.SendAndWaitAsync( new MessageOptions { - Prompt = $"Use the create tool to create {FileName} containing exactly {FileContent}. " + Prompt = $"Use the edit tool to replace the exact contents of {FileName} " + + $"from {PreparedFileContent} to {FileContent}. " + "After the tool succeeds, reply with exactly SDK_REWIND_DONE.", }, TimeSpan.FromSeconds(30)); @@ -47,9 +58,10 @@ await TestHelper.WaitForConditionAsync( { rewindPoints = await session.Rpc.History.ListRewindPointsAsync(); return rewindPoints.UnavailableReason is null - && rewindPoints.Points.Count == 1 - && rewindPoints.Points[0].CanRestoreFiles - && rewindPoints.Points[0].FileCount == 1; + && rewindPoints.Points.Count == 2 + && rewindPoints.Points[1].TurnChangedFiles + && rewindPoints.Points[1].CanRestoreFiles + && rewindPoints.Points[1].FileCount == 1; }, timeout: TimeSpan.FromSeconds(30), timeoutMessage: "Timed out waiting for a restorable file rewind point.", @@ -57,7 +69,9 @@ await TestHelper.WaitForConditionAsync( Assert.NotNull(rewindPoints); Assert.True(rewindPoints.FileChangeTrackingEnabled); - var rewindPoint = Assert.Single(rewindPoints.Points); + Assert.Equal(2, rewindPoints.Points.Count); + var rewindPoint = rewindPoints.Points[1]; + Assert.True(rewindPoint.TurnChangedFiles); Assert.True(rewindPoint.CanRestoreFiles); Assert.Equal(1, rewindPoint.FileCount); @@ -80,7 +94,7 @@ await TestHelper.WaitForConditionAsync( Path.GetFullPath(filePath), Path.GetFullPath(restoredFile), OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); - Assert.False(File.Exists(filePath)); + Assert.Equal(PreparedFileContent, await File.ReadAllTextAsync(filePath)); var events = await session.GetEventsAsync(); Assert.DoesNotContain(events, sessionEvent => sessionEvent.Id.ToString() == rewindPoint.EventId); diff --git a/dotnet/test/Unit/MSBuildTargetsTests.cs b/dotnet/test/Unit/MSBuildTargetsTests.cs index a7d9cc0256..cab91e568f 100644 --- a/dotnet/test/Unit/MSBuildTargetsTests.cs +++ b/dotnet/test/Unit/MSBuildTargetsTests.cs @@ -48,6 +48,7 @@ public async Task PreinstalledCliBinaryPath_IsHonored_DownloadSkipped_AndCopiedT var outputPath = sandbox.ExpectedOutputBinary(); Assert.True(File.Exists(outputPath), $"Expected CLI to be copied to '{outputPath}'.\n{result.FailureMessage()}"); Assert.Equal(File.ReadAllText(preinstalled), File.ReadAllText(outputPath)); + Assert.True(File.Exists(Path.Combine(Path.GetDirectoryName(outputPath)!, ".copilot-explicit-cli"))); } [Fact] @@ -105,6 +106,35 @@ public async Task PreinstalledCliBinaryPath_WithSkipCliDownload_StillCopiesToOut Assert.True(File.Exists(sandbox.ExpectedOutputBinary()), result.FailureMessage()); } + [Fact] + public async Task RuntimePackageAssets_AreFilteredAndCopiedToOutput() + { + using var sandbox = MSBuildSandbox.Create(); + var preinstalled = sandbox.WritePreinstalledBinary("fake-cli-contents"); + sandbox.WriteRuntimeCacheAsset("prebuilds", GetNpmPlatform(), "runtime.node", "runtime"); + sandbox.WriteRuntimeCacheAsset("prebuilds", GetNpmPlatform(), + OperatingSystem.IsWindows() ? "copilot-runtime.exe" : "copilot-runtime", "wrapper"); + sandbox.WriteRuntimeCacheAsset("ripgrep", "bin", GetNpmPlatform(), "rg", "ripgrep"); + sandbox.WriteRuntimeCacheAsset("definitions", "future.json", "{}"); + sandbox.WriteRuntimeCacheAsset("app.js", "excluded"); + sandbox.WriteRuntimeCacheAsset("LICENSE.md", "excluded"); + sandbox.WriteRuntimeCacheAsset("README.md", "excluded"); + sandbox.WriteStaleOutputRuntimeAsset("obsolete", "tool", "stale"); + + var result = await sandbox.BuildAsync(new Dictionary + { + ["CopilotCliBinaryPath"] = preinstalled, + }); + + Assert.True(result.Succeeded, result.FailureMessage()); + Assert.Equal("ripgrep", File.ReadAllText(sandbox.ExpectedRuntimeAsset("ripgrep", "bin", GetNpmPlatform(), "rg"))); + Assert.Equal("{}", File.ReadAllText(sandbox.ExpectedRuntimeAsset("definitions", "future.json"))); + Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("app.js"))); + Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("LICENSE.md"))); + Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("README.md"))); + Assert.False(File.Exists(sandbox.ExpectedRuntimeAsset("obsolete", "tool"))); + } + [Fact] public async Task PreinstalledCliBinaryPath_NonExistentFile_FailsWithActionableError() { @@ -150,6 +180,17 @@ private static string FindTargetsFile([CallerFilePath] string? thisFile = null) "Could not locate GitHub.Copilot.SDK.targets relative to test assembly or source file."); } + private static string GetNpmPlatform() + { + var arch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture + == System.Runtime.InteropServices.Architecture.Arm64 + ? "arm64" + : "x64"; + if (OperatingSystem.IsWindows()) return $"win32-{arch}"; + if (OperatingSystem.IsMacOS()) return $"darwin-{arch}"; + return $"linux-{arch}"; + } + /// /// A throwaway directory containing a minimal csproj that imports the SDK targets /// file. Disposing removes the directory tree. @@ -203,6 +244,42 @@ public string ExpectedOutputBinary() return Path.Combine(ProjectDir, "bin", "Debug", "net8.0", "runtimes", rid, "native", BinaryName); } + public void WriteRuntimeCacheAsset(params string[] pathAndContents) + { + var pathParts = pathAndContents.Take(pathAndContents.Length - 1).ToArray(); + var path = Path.Combine(ProjectDir, "obj", "Debug", "net8.0", "copilot-cli", "0.0.0-test", + GetNpmPlatform()); + foreach (var part in pathParts) + { + path = Path.Combine(path, part); + } + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, pathAndContents[^1]); + } + + public string ExpectedRuntimeAsset(params string[] pathParts) + { + var path = Path.Combine(ProjectDir, "bin", "Debug", "net8.0", "runtimes", GetPortableRid(), "native"); + foreach (var part in pathParts) + { + path = Path.Combine(path, part); + } + return path; + } + + public void WriteStaleOutputRuntimeAsset(params string[] pathAndContents) + { + var relativeParts = pathAndContents.Take(pathAndContents.Length - 1).ToArray(); + var path = ExpectedRuntimeAsset(relativeParts); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, pathAndContents[^1]); + var manifest = ExpectedRuntimeAsset(".copilot-runtime-assets"); + Directory.CreateDirectory(Path.GetDirectoryName(manifest)!); + File.WriteAllText( + manifest, + string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts) + Environment.NewLine); + } + public async Task BuildAsync(IDictionary properties) { var args = new StringBuilder("build --nologo -clp:NoSummary"); diff --git a/dotnet/test/Unit/RuntimeWrapperTests.cs b/dotnet/test/Unit/RuntimeWrapperTests.cs new file mode 100644 index 0000000000..9a9dacb7d3 --- /dev/null +++ b/dotnet/test/Unit/RuntimeWrapperTests.cs @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class RuntimeWrapperIsolationCollection +{ + public const string Name = "Runtime wrapper isolation"; +} + +[Collection(RuntimeWrapperIsolationCollection.Name)] +public sealed class RuntimeWrapperTests +{ +#if !NETFRAMEWORK + [Fact] + public async Task Managed_Launch_Fails_When_Bundled_Runtime_Pair_Is_Missing() + { + var originalBaseDirectory = AppContext.GetData("APP_CONTEXT_BASE_DIRECTORY"); + var emptyBaseDirectory = Path.Combine( + Path.GetTempPath(), + $"missing-copilot-runtime-{Guid.NewGuid():N}"); + Directory.CreateDirectory(emptyBaseDirectory); + + try + { + AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", emptyBaseDirectory); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(), + Environment = new Dictionary(), + }); + + var exception = await Assert.ThrowsAsync(() => client.StartAsync()); + + Assert.Contains("runtime wrapper not found", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", originalBaseDirectory); + Directory.Delete(emptyBaseDirectory); + } + } +#endif + + [Fact] + public async Task Explicit_Path_Does_Not_Require_Adjacent_Runtime_Node() + { + var explicitPath = Path.Combine( + Path.GetTempPath(), + $"missing-explicit-copilot-{Guid.NewGuid():N}"); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(path: explicitPath), + Environment = new Dictionary(), + }); + + var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); + + Assert.DoesNotContain("runtime.node", exception.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Copilot_Cli_Path_Does_Not_Require_Adjacent_Runtime_Node() + { + var explicitPath = Path.Combine( + Path.GetTempPath(), + $"missing-environment-copilot-{Guid.NewGuid():N}"); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(), + Environment = new Dictionary { ["COPILOT_CLI_PATH"] = explicitPath }, + }); + + var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); + + Assert.DoesNotContain("runtime.node", exception.ToString(), StringComparison.OrdinalIgnoreCase); + } + +#if !NETFRAMEWORK + [Fact] + public async Task Marked_Bundled_Explicit_Cli_Does_Not_Require_Runtime_Pair() + { + var originalBaseDirectory = AppContext.GetData("APP_CONTEXT_BASE_DIRECTORY"); + var baseDirectory = Path.Combine( + Path.GetTempPath(), + $"explicit-bundled-copilot-{Guid.NewGuid():N}"); + var rid = GetPortableRid(); + var nativeDirectory = Path.Combine(baseDirectory, "runtimes", rid, "native"); + Directory.CreateDirectory(nativeDirectory); + var cliPath = Path.Combine(nativeDirectory, OperatingSystem.IsWindows() ? "copilot.exe" : "copilot"); + await File.WriteAllTextAsync(cliPath, "not an executable"); + await File.WriteAllTextAsync(Path.Combine(nativeDirectory, ".copilot-explicit-cli"), "explicit"); + + try + { + AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDirectory); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(), + Environment = new Dictionary(), + }); + + var exception = await Assert.ThrowsAnyAsync(() => client.StartAsync()); + + Assert.DoesNotContain("runtime wrapper", exception.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(cliPath, exception.ToString(), StringComparison.OrdinalIgnoreCase); + } + finally + { + AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", originalBaseDirectory); + Directory.Delete(baseDirectory, recursive: true); + } + } +#endif + + private static string GetPortableRid() + { + var os = OperatingSystem.IsWindows() ? "win" + : OperatingSystem.IsMacOS() ? "osx" + : "linux"; + var architecture = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + { + System.Runtime.InteropServices.Architecture.X64 => "x64", + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException(), + }; + return $"{os}-{architecture}"; + } +} diff --git a/go/README.md b/go/README.md index 3d8706cec1..6c85f93ae1 100644 --- a/go/README.md +++ b/go/README.md @@ -104,7 +104,7 @@ Follow these steps to embed the CLI: 1. Run `go get -tool github.com/github/copilot-sdk/go/cmd/bundler`. This is a one-time setup step per project. 2. Run `go tool bundler` in your build environment just before building your application. -That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`) and no `COPILOT_CLI_PATH` environment variable, the SDK will automatically install the embedded CLI to a cache directory and use it for all operations. +That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`), the SDK automatically installs the embedded `copilot-runtime` executable and adjacent `runtime.node` to a cache directory for managed child-process connections. The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag. @@ -138,6 +138,9 @@ Resolution and requirements: always takes precedence. - Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package; otherwise the bundled runtime is used. No `PATH` lookup is performed. - Embedded runtime versions are isolated in separate cache directories. Start fails loudly if the native runtime is unavailable. +- Managed child-process start fails if the embedded `copilot-runtime` and + `runtime.node` pair is unavailable; explicit paths and `COPILOT_CLI_PATH` + remain direct overrides. - Linux in-process bundles include both glibc and musl runtime packages and select the matching package automatically at startup. - Only one native runtime version may be loaded per process. @@ -195,7 +198,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `URIConnection{URL, ConnectionToken}` — connect to an already-running runtime (no process spawned) - `InProcessConnection{}` — **Experimental.** Host the runtime in-process via the native FFI library instead of spawning a child process. See [In-process transport](#in-process-transport-experimental) below. - When `Path` is empty for stdio/tcp, the SDK uses the bundled CLI (or `COPILOT_CLI_PATH` env var). + When `Path` is empty for stdio/tcp, the SDK uses `COPILOT_CLI_PATH` when set, then the bundled `copilot-runtime` and adjacent `runtime.node`. `StdioConnection` and `TCPConnection` accept an optional connection-level `Env`. Set environment variables via **either** the client-level `Env` option or the connection's `Env`, not both (setting both panics); prefer the connection-level `Env`. - `WorkingDirectory` (string): Working directory for the runtime process (default: current process working directory) diff --git a/go/client.go b/go/client.go index 2dbd7f7e95..1b45a9f142 100644 --- a/go/client.go +++ b/go/client.go @@ -341,6 +341,18 @@ func NewClient(options *ClientOptions) *Client { return client } +func resolveRuntimeExecutable(explicitPath, bundledRuntimePath string) (string, error) { + if explicitPath != "" { + return explicitPath, nil + } + if bundledRuntimePath == "" { + return "", errors.New( + "managed Copilot runtime unavailable: the embedded bundle does not contain copilot-runtime and adjacent runtime.node; regenerate the bundle, provide an explicit path, or set COPILOT_CLI_PATH", + ) + } + return bundledRuntimePath, nil +} + const defaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION" // resolveDefaultConnection selects the transport when no explicit connection @@ -1987,14 +1999,13 @@ func (c *Client) startCLIServer(ctx context.Context) error { return c.startInProcess(ctx) } - cliPath := c.cliPath - if cliPath == "" { - // If no CLI path is provided, attempt to use the embedded CLI if available - cliPath = embeddedcli.Path() + bundledRuntimePath := "" + if c.cliPath == "" { + bundledRuntimePath = embeddedcli.RuntimePath() } - if cliPath == "" { - // Default to "copilot" in PATH if no embedded CLI is available and no custom path is set - cliPath = "copilot" + cliPath, err := resolveRuntimeExecutable(c.cliPath, bundledRuntimePath) + if err != nil { + return err } // Start with user-provided CLIArgs, then add SDK-managed args @@ -2195,24 +2206,21 @@ func (c *Client) startInProcess(ctx context.Context) error { return errors.New("in-process transport unavailable: rebuild with -tags copilot_inprocess on a supported platform") } - runtimePath := c.cliPath - if runtimePath == "" { - // The in-process transport does not resolve a bare command name from PATH - // (unlike the child-process transport). - if p := getEnvValue(c.options.Env, "COPILOT_CLI_PATH"); p != "" { - runtimePath = p - } + cliEntrypoint := c.cliPath + if cliEntrypoint == "" { + cliEntrypoint = getEnvValue(c.options.Env, "COPILOT_CLI_PATH") } + runtimePath := cliEntrypoint if runtimePath == "" { - runtimePath = embeddedcli.Path() + runtimePath = embeddedcli.RuntimePath() } if runtimePath == "" { - return errors.New("in-process runtime unavailable: set COPILOT_CLI_PATH to a compatible runtime package or build with the bundled embedded runtime") + return errors.New("in-process runtime unavailable: build with the bundled embedded runtime or set COPILOT_CLI_PATH to a compatible runtime package") } config := c.inProcessHostConfig() - host, err := createInProcessHost(runtimePath, config) + host, err := createInProcessHost(runtimePath, cliEntrypoint, config) if err != nil { return err } diff --git a/go/client_test.go b/go/client_test.go index b701de437f..d49aff1cc1 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -24,6 +24,35 @@ import ( // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.go instead +func TestResolveRuntimeExecutable(t *testing.T) { + t.Run("managed launch requires bundled runtime pair", func(t *testing.T) { + _, err := resolveRuntimeExecutable("", "") + if err == nil || !strings.Contains(err.Error(), "copilot-runtime and adjacent runtime.node") { + t.Fatalf("expected missing managed runtime error, got %v", err) + } + }) + + t.Run("explicit path does not require bundled runtime pair", func(t *testing.T) { + path, err := resolveRuntimeExecutable("/explicit/copilot", "") + if err != nil { + t.Fatal(err) + } + if path != "/explicit/copilot" { + t.Fatalf("resolveRuntimeExecutable() = %q", path) + } + }) + + t.Run("managed launch selects bundled wrapper", func(t *testing.T) { + path, err := resolveRuntimeExecutable("", "/bundle/copilot-runtime") + if err != nil { + t.Fatal(err) + } + if path != "/bundle/copilot-runtime" { + t.Fatalf("resolveRuntimeExecutable() = %q", path) + } + }) +} + func TestClient_URLParsing(t *testing.T) { t.Run("should parse port-only URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go index e63d1fde66..fb11ff8699 100644 --- a/go/cmd/bundler/main.go +++ b/go/cmd/bundler/main.go @@ -91,21 +91,20 @@ func main() { fmt.Printf("Building bundle for %s (CLI version %s)\n", *platform, version) - binaryPath, sha256Hash, runtimeArtifactPath, runtimeHash, err := buildBundle(info, version, outputPath, goos) + bundle, err := buildBundle(info, version, outputPath, goos) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - var muslBinaryPath, muslRuntimeArtifactPath string - var muslBinaryHash, muslRuntimeHash []byte + var muslBundle bundleArtifacts if goos == "linux" { muslInfo := platformInfo{ npmPlatform: strings.Replace(info.npmPlatform, "linux-", "linuxmusl-", 1), binaryName: info.binaryName, } muslOutputPath := filepath.Join(*output, defaultOutputFileName(version, "linuxmusl", goarch, info.binaryName)) - muslBinaryPath, muslBinaryHash, muslRuntimeArtifactPath, muslRuntimeHash, err = buildBundle( + muslBundle, err = buildBundle( muslInfo, version, muslOutputPath, @@ -121,15 +120,23 @@ func main() { if err := generateGoFile( goos, goarch, - binaryPath, + bundle.binaryPath, version, - sha256Hash, - runtimeArtifactPath, - runtimeHash, - muslBinaryPath, - muslBinaryHash, - muslRuntimeArtifactPath, - muslRuntimeHash, + bundle.binaryHash, + bundle.runtimeArtifactPath, + bundle.runtimeHash, + bundle.wrapperArtifactPath, + bundle.wrapperHash, + bundle.assetsArtifactPath, + bundle.assetsHash, + muslBundle.binaryPath, + muslBundle.binaryHash, + muslBundle.runtimeArtifactPath, + muslBundle.runtimeHash, + muslBundle.wrapperArtifactPath, + muslBundle.wrapperHash, + muslBundle.assetsArtifactPath, + muslBundle.assetsHash, "main", ); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -286,96 +293,137 @@ func isHex(s string) bool { return true } -// buildBundle downloads the CLI binary (and, when the CLI package ships it, the -// native in-process runtime library) and writes them to outputPath's directory. -// It returns the CLI bundle path and hash, plus the runtime-library artifact path -// and hash (both empty when the package does not ship the runtime library). -func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (string, []byte, string, []byte, error) { +type bundleArtifacts struct { + binaryPath string + binaryHash []byte + runtimeArtifactPath string + runtimeHash []byte + wrapperArtifactPath string + wrapperHash []byte + assetsArtifactPath string + assetsHash []byte +} + +// buildBundle downloads the CLI and native runtime artifacts from one platform package. +func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundleArtifacts, error) { outputDir := filepath.Dir(outputPath) if outputDir == "" { outputDir = "." } runtimeArtifactPath := filepath.Join(outputDir, runtimeLibArtifactName(cliVersion, info.npmPlatform, goos)) + wrapperArtifactPath := filepath.Join(outputDir, runtimeWrapperArtifactName(cliVersion, info.npmPlatform, info.binaryName)) + assetsArtifactPath := filepath.Join(outputDir, runtimeAssetsArtifactName(cliVersion, info.npmPlatform)) - // Check if output already exists - if _, err := os.Stat(outputPath); err == nil { + if filesExist(outputPath, runtimeArtifactPath, wrapperArtifactPath, assetsArtifactPath) { // Idempotent output avoids re-downloading in CI or local rebuilds. - fmt.Printf("Output %s already exists, skipping download\n", outputPath) - sha256Hash, err := sha256FileFromCompressed(outputPath) + fmt.Printf("Output runtime bundle for %s already exists, skipping download\n", info.npmPlatform) + binaryHash, err := sha256FileFromCompressed(outputPath) if err != nil { - return "", nil, "", nil, fmt.Errorf("failed to hash existing output: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to hash existing output: %w", err) } if err := downloadCLILicense(cliVersion, outputPath); err != nil { - return "", nil, "", nil, fmt.Errorf("failed to download CLI license: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to download CLI license: %w", err) } - // Reuse an existing runtime-library artifact if present. - if _, err := os.Stat(runtimeArtifactPath); err == nil { - runtimeHash, err := sha256FileFromCompressed(runtimeArtifactPath) - if err != nil { - return "", nil, "", nil, fmt.Errorf("failed to hash existing runtime library: %w", err) - } - return outputPath, sha256Hash, runtimeArtifactPath, runtimeHash, nil + runtimeHash, err := sha256FileFromCompressed(runtimeArtifactPath) + if err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to hash existing runtime.node: %w", err) } - return outputPath, sha256Hash, "", nil, nil + wrapperHash, err := sha256FileFromCompressed(wrapperArtifactPath) + if err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to hash existing runtime wrapper: %w", err) + } + assetsHash, err := sha256File(assetsArtifactPath) + if err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to hash existing runtime assets: %w", err) + } + return bundleArtifacts{outputPath, binaryHash, runtimeArtifactPath, runtimeHash, wrapperArtifactPath, wrapperHash, assetsArtifactPath, assetsHash}, nil } + // Create temp directory for download tempDir, err := os.MkdirTemp("", "copilot-bundler-*") if err != nil { - return "", nil, "", nil, fmt.Errorf("failed to create temp dir: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to create temp dir: %w", err) } defer os.RemoveAll(tempDir) - // Download the binary binaryPath, tarballPath, err := downloadCLIBinary(info.npmPlatform, info.binaryName, cliVersion, tempDir) if err != nil { - return "", nil, "", nil, fmt.Errorf("failed to download CLI binary: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to download CLI binary: %w", err) } - // Create output directory if needed if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { - return "", nil, "", nil, fmt.Errorf("failed to create output directory: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to create output directory: %w", err) } } - sha256Hash, err := sha256File(binaryPath) + binaryHash, err := sha256File(binaryPath) if err != nil { - return "", nil, "", nil, fmt.Errorf("failed to hash output binary: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to hash output binary: %w", err) } if err := compressZstdFile(binaryPath, outputPath); err != nil { - return "", nil, "", nil, fmt.Errorf("failed to write output binary: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to write output binary: %w", err) } if err := downloadCLILicense(cliVersion, outputPath); err != nil { - return "", nil, "", nil, fmt.Errorf("failed to download CLI license: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to download CLI license: %w", err) } - // Extract the native in-process runtime library from the same tarball, if the - // package ships it (older CLI versions do not). Missing is not an error — the - // generated file simply omits the runtime embed for that platform. rawLibPath := filepath.Join(tempDir, "runtime.node") - found, err := extractOptionalFileFromTarball(tarballPath, tempDir, - "package/prebuilds/"+info.npmPlatform+"/runtime.node", "runtime.node") + if err := extractFileFromTarball( + tarballPath, + tempDir, + "package/prebuilds/"+info.npmPlatform+"/runtime.node", + "runtime.node", + ); err != nil { + return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/runtime.node: %w", info.npmPlatform, err) + } + runtimeHash, err := sha256File(rawLibPath) if err != nil { - return "", nil, "", nil, fmt.Errorf("failed to extract runtime library: %w", err) + return bundleArtifacts{}, fmt.Errorf("failed to hash runtime.node: %w", err) } - var runtimeHash []byte - returnedRuntimeArtifact := "" - if found { - runtimeHash, err = sha256File(rawLibPath) - if err != nil { - return "", nil, "", nil, fmt.Errorf("failed to hash runtime library: %w", err) - } - if err := compressZstdFile(rawLibPath, runtimeArtifactPath); err != nil { - return "", nil, "", nil, fmt.Errorf("failed to write runtime library: %w", err) - } - returnedRuntimeArtifact = runtimeArtifactPath - fmt.Printf("Successfully created %s\n", runtimeArtifactPath) - } else { - fmt.Printf("Package %s does not ship a runtime library; in-process transport unavailable for this platform bundle\n", info.npmPlatform) + if err := compressZstdFile(rawLibPath, runtimeArtifactPath); err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to write runtime.node: %w", err) + } + + wrapperName := runtimeWrapperName(info.binaryName) + rawWrapperPath := filepath.Join(tempDir, wrapperName) + if err := extractFileFromTarball( + tarballPath, + tempDir, + "package/prebuilds/"+info.npmPlatform+"/"+wrapperName, + wrapperName, + ); err != nil { + return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/%s: %w", info.npmPlatform, wrapperName, err) + } + wrapperHash, err := sha256File(rawWrapperPath) + if err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to hash runtime wrapper: %w", err) + } + if err := compressZstdFile(rawWrapperPath, wrapperArtifactPath); err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to write runtime wrapper: %w", err) + } + if err := createRuntimeAssetsArchive(tarballPath, assetsArtifactPath, info); err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to write runtime assets: %w", err) + } + assetsHash, err := sha256File(assetsArtifactPath) + if err != nil { + return bundleArtifacts{}, fmt.Errorf("failed to hash runtime assets: %w", err) } fmt.Printf("Successfully created %s\n", outputPath) - return outputPath, sha256Hash, returnedRuntimeArtifact, runtimeHash, nil + fmt.Printf("Successfully created %s\n", runtimeArtifactPath) + fmt.Printf("Successfully created %s\n", wrapperArtifactPath) + fmt.Printf("Successfully created %s\n", assetsArtifactPath) + return bundleArtifacts{outputPath, binaryHash, runtimeArtifactPath, runtimeHash, wrapperArtifactPath, wrapperHash, assetsArtifactPath, assetsHash}, nil +} + +func filesExist(paths ...string) bool { + for _, path := range paths { + if _, err := os.Stat(path); err != nil { + return false + } + } + return true } // runtimeLibArtifactName builds the compressed runtime-library artifact filename. @@ -383,6 +431,117 @@ func runtimeLibArtifactName(version, npmPlatform, goos string) string { return fmt.Sprintf("zcopilotruntime_%s_%s.%s.zst", version, npmPlatform, runtimeLibExt(goos)) } +func runtimeWrapperArtifactName(version, npmPlatform, binaryName string) string { + return fmt.Sprintf("zcopilotruntimewrapper_%s_%s_%s.zst", version, npmPlatform, runtimeWrapperName(binaryName)) +} + +func runtimeAssetsArtifactName(version, npmPlatform string) string { + return fmt.Sprintf("zcopilotruntimeassets_%s_%s.tgz", version, npmPlatform) +} + +func runtimeWrapperName(binaryName string) string { + if filepath.Ext(binaryName) == ".exe" { + return "copilot-runtime.exe" + } + return "copilot-runtime" +} + +var hostlessExcludedTopLevel = map[string]bool{ + "app.js": true, "assets": true, "changelog.json": true, "copilot": true, "copilot.exe": true, + "copilot-sdk": true, "foundry-local-sdk": true, "index.js": true, "napi-oop-runtime": true, + "LICENSE.md": true, "npm-loader.js": true, "package.json": true, "preloads": true, "pvrecorder": true, + "queries": true, "README.md": true, "sdk": true, "sea-loader.js": true, "webview": true, +} + +func hostlessRuntimePath(name, npmPlatform, wrapperName string) (string, bool) { + relative, ok := strings.CutPrefix(name, "package/") + if !ok { + return "", false + } + parts := strings.Split(relative, "/") + topLevel := parts[0] + fileName := parts[len(parts)-1] + if hostlessExcludedTopLevel[topLevel] || + (strings.HasPrefix(topLevel, "tree-sitter") && strings.HasSuffix(topLevel, ".wasm")) || + (strings.HasPrefix(topLevel, "voice-") && strings.HasSuffix(topLevel, ".js")) || + fileName == "cli-native.node" || fileName == "runtime.node" || fileName == wrapperName || + strings.HasPrefix(fileName, "copilot-runtime-bin") { + return "", false + } + for _, part := range parts { + if part == "mediaremote-adapter" { + return "", false + } + } + if topLevel == "prebuilds" { + if len(parts) < 3 || parts[1] != npmPlatform { + return "", false + } + return strings.Join(parts[2:], "/"), true + } + return relative, true +} + +func createRuntimeAssetsArchive(tarballPath, outputPath string, info platformInfo) error { + sourceFile, err := os.Open(tarballPath) + if err != nil { + return err + } + defer sourceFile.Close() + gzipReader, err := gzip.NewReader(sourceFile) + if err != nil { + return err + } + defer gzipReader.Close() + outputFile, err := os.Create(outputPath) + if err != nil { + return err + } + defer outputFile.Close() + gzipWriter := gzip.NewWriter(outputFile) + tarWriter := tar.NewWriter(gzipWriter) + count := 0 + sourceTar := tar.NewReader(gzipReader) + for { + header, err := sourceTar.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if header.Typeflag != tar.TypeReg { + continue + } + destination, include := hostlessRuntimePath( + header.Name, + info.npmPlatform, + runtimeWrapperName(info.binaryName), + ) + if !include { + continue + } + outputHeader := &tar.Header{ + Name: destination, Mode: header.Mode, Size: header.Size, Typeflag: tar.TypeReg, + Uid: 0, Gid: 0, + } + if err := tarWriter.WriteHeader(outputHeader); err != nil { + return err + } + if _, err := io.Copy(tarWriter, sourceTar); err != nil { + return err + } + count++ + } + if count == 0 { + return fmt.Errorf("runtime package contains no retained assets") + } + if err := tarWriter.Close(); err != nil { + return err + } + return gzipWriter.Close() +} + // runtimeLibExt returns the shared-library extension for the target OS. func runtimeLibExt(goos string) string { switch goos { @@ -406,10 +565,18 @@ func generateGoFile( sha256Hash []byte, runtimeArtifactPath string, runtimeHash []byte, + wrapperArtifactPath string, + wrapperHash []byte, + assetsArtifactPath string, + assetsHash []byte, muslBinaryPath string, muslBinaryHash []byte, muslRuntimeArtifactPath string, muslRuntimeHash []byte, + muslWrapperArtifactPath string, + muslWrapperHash []byte, + muslAssetsArtifactPath string, + muslAssetsHash []byte, pkgName string, ) error { binaryName := filepath.Base(binaryPath) @@ -428,12 +595,20 @@ func generateGoFile( licenseName, cliVersion, hashBase64, - "", - nil, - "", - nil, - "", - nil, + runtimeArtifactPath, + runtimeHash, + wrapperArtifactPath, + wrapperHash, + assetsArtifactPath, + assetsHash, + muslBinaryPath, + muslBinaryHash, + muslRuntimeArtifactPath, + muslRuntimeHash, + muslWrapperArtifactPath, + muslWrapperHash, + muslAssetsArtifactPath, + muslAssetsHash, ) if err := os.WriteFile(defaultPath, []byte(defaultContent), 0644); err != nil { return err @@ -449,10 +624,18 @@ func generateGoFile( hashBase64, runtimeArtifactPath, runtimeHash, + wrapperArtifactPath, + wrapperHash, + assetsArtifactPath, + assetsHash, muslBinaryPath, muslBinaryHash, muslRuntimeArtifactPath, muslRuntimeHash, + muslWrapperArtifactPath, + muslWrapperHash, + muslAssetsArtifactPath, + muslAssetsHash, ) if err := os.WriteFile(inProcessPath, []byte(inProcessContent), 0644); err != nil { return err @@ -472,24 +655,48 @@ func generatedGoFileContent( hashBase64, runtimeArtifactPath string, runtimeHash []byte, + wrapperArtifactPath string, + wrapperHash []byte, + assetsArtifactPath string, + assetsHash []byte, muslBinaryPath string, muslBinaryHash []byte, muslRuntimeArtifactPath string, muslRuntimeHash []byte, + muslWrapperArtifactPath string, + muslWrapperHash []byte, + muslAssetsArtifactPath string, + muslAssetsHash []byte, ) string { runtimeEmbed := "" runtimeConfig := "" runtimeReader := "" - if runtimeArtifactPath != "" { + if runtimeArtifactPath != "" && wrapperArtifactPath != "" && assetsArtifactPath != "" { runtimeArtifactName := filepath.Base(runtimeArtifactPath) runtimeHashBase64 := base64.StdEncoding.EncodeToString(runtimeHash) + wrapperArtifactName := filepath.Base(wrapperArtifactPath) + wrapperHashBase64 := base64.StdEncoding.EncodeToString(wrapperHash) + assetsArtifactName := filepath.Base(assetsArtifactPath) + assetsHashBase64 := base64.StdEncoding.EncodeToString(assetsHash) runtimeEmbed = fmt.Sprintf(` //go:embed %s var localEmbeddedCopilotRuntimeLib []byte -`, runtimeArtifactName) + +//go:embed %s +var localEmbeddedCopilotRuntimeExecutable []byte + +//go:embed %s +var localEmbeddedCopilotRuntimeAssets []byte +`, runtimeArtifactName, wrapperArtifactName, assetsArtifactName) runtimeConfig = fmt.Sprintf(` - RuntimeLib: runtimeLibReader(), - RuntimeLibHash: mustDecodeBase64(%q),`, runtimeHashBase64) + RuntimeLib: runtimeLibReader(), + RuntimeLibHash: mustDecodeBase64(%q), + RuntimeNode: runtimeLibReader(), + RuntimeNodeHash: mustDecodeBase64(%q), + RuntimeExecutable: runtimeExecutableReader(), + RuntimeExecutableHash: mustDecodeBase64(%q), + RuntimeAssets: bytes.NewReader(localEmbeddedCopilotRuntimeAssets), + RuntimeAssetsHash: mustDecodeBase64(%q),`, runtimeHashBase64, runtimeHashBase64, wrapperHashBase64, assetsHashBase64) runtimeReader = ` func runtimeLibReader() io.Reader { r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeLib)) @@ -498,29 +705,53 @@ func runtimeLibReader() io.Reader { } return r } + +func runtimeExecutableReader() io.Reader { + r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeExecutable)) + if err != nil { + panic("failed to create zstd reader: " + err.Error()) + } + return r +} ` } muslEmbed := "" muslConfig := "" muslReaders := "" - if muslBinaryPath != "" && muslRuntimeArtifactPath != "" { + if muslBinaryPath != "" && muslRuntimeArtifactPath != "" && muslWrapperArtifactPath != "" && muslAssetsArtifactPath != "" { muslBinaryName := filepath.Base(muslBinaryPath) muslBinaryHashBase64 := base64.StdEncoding.EncodeToString(muslBinaryHash) muslRuntimeName := filepath.Base(muslRuntimeArtifactPath) muslRuntimeHashBase64 := base64.StdEncoding.EncodeToString(muslRuntimeHash) + muslWrapperName := filepath.Base(muslWrapperArtifactPath) + muslWrapperHashBase64 := base64.StdEncoding.EncodeToString(muslWrapperHash) + muslAssetsName := filepath.Base(muslAssetsArtifactPath) + muslAssetsHashBase64 := base64.StdEncoding.EncodeToString(muslAssetsHash) muslEmbed = fmt.Sprintf(` //go:embed %s var localEmbeddedCopilotCLILinuxMusl []byte //go:embed %s var localEmbeddedCopilotRuntimeLibLinuxMusl []byte -`, muslBinaryName, muslRuntimeName) + +//go:embed %s +var localEmbeddedCopilotRuntimeExecutableLinuxMusl []byte + +//go:embed %s +var localEmbeddedCopilotRuntimeAssetsLinuxMusl []byte +`, muslBinaryName, muslRuntimeName, muslWrapperName, muslAssetsName) muslConfig = fmt.Sprintf(` - LinuxMuslCli: linuxMuslCLIReader(), - LinuxMuslCliHash: mustDecodeBase64(%q), - LinuxMuslRuntimeLib: linuxMuslRuntimeLibReader(), - LinuxMuslRuntimeLibHash: mustDecodeBase64(%q),`, muslBinaryHashBase64, muslRuntimeHashBase64) + LinuxMuslCli: linuxMuslCLIReader(), + LinuxMuslCliHash: mustDecodeBase64(%q), + LinuxMuslRuntimeLib: linuxMuslRuntimeLibReader(), + LinuxMuslRuntimeLibHash: mustDecodeBase64(%q), + LinuxMuslRuntimeNode: linuxMuslRuntimeLibReader(), + LinuxMuslRuntimeNodeHash: mustDecodeBase64(%q), + LinuxMuslRuntimeExecutable: linuxMuslRuntimeExecutableReader(), + LinuxMuslRuntimeExecutableHash: mustDecodeBase64(%q), + LinuxMuslRuntimeAssets: bytes.NewReader(localEmbeddedCopilotRuntimeAssetsLinuxMusl), + LinuxMuslRuntimeAssetsHash: mustDecodeBase64(%q),`, muslBinaryHashBase64, muslRuntimeHashBase64, muslRuntimeHashBase64, muslWrapperHashBase64, muslAssetsHashBase64) muslReaders = ` func linuxMuslCLIReader() io.Reader { r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotCLILinuxMusl)) @@ -537,6 +768,14 @@ func linuxMuslRuntimeLibReader() io.Reader { } return r } + +func linuxMuslRuntimeExecutableReader() io.Reader { + r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeExecutableLinuxMusl)) + if err != nil { + panic("failed to create zstd reader: " + err.Error()) + } + return r +} ` } diff --git a/go/cmd/bundler/main_test.go b/go/cmd/bundler/main_test.go index badc791359..0a7f1c3876 100644 --- a/go/cmd/bundler/main_test.go +++ b/go/cmd/bundler/main_test.go @@ -1,26 +1,126 @@ package main import ( + "archive/tar" + "bytes" + "compress/gzip" "go/parser" "go/token" + "io" "os" "path/filepath" "strings" "testing" ) -func TestGenerateGoFileGatesRuntimeEmbed(t *testing.T) { +func TestCreateRuntimeAssetsArchiveRetainsUnknownAssetsAndFiltersCLIContent(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "package.tgz") + output := filepath.Join(dir, "assets.tgz") + writeTarGz(t, source, map[string]string{ + "package/prebuilds/linux-x64/runtime.node": "runtime", + "package/prebuilds/linux-x64/copilot-runtime": "wrapper", + "package/ripgrep/bin/linux-x64/rg": "ripgrep", + "package/definitions/future.json": "{}", + "package/app.js": "excluded", + "package/LICENSE.md": "excluded", + "package/README.md": "excluded", + }) + + if err := createRuntimeAssetsArchive(source, output, platformInfo{ + npmPlatform: "linux-x64", + binaryName: "copilot", + }); err != nil { + t.Fatal(err) + } + + files := readTarGz(t, output) + if files["ripgrep/bin/linux-x64/rg"] != "ripgrep" || files["definitions/future.json"] != "{}" { + t.Fatalf("retained assets = %#v", files) + } + for _, excluded := range []string{ + "runtime.node", "copilot-runtime", "app.js", "LICENSE.md", "README.md", + } { + if _, ok := files[excluded]; ok { + t.Fatalf("excluded asset %q was retained", excluded) + } + } +} + +func writeTarGz(t *testing.T, path string, files map[string]string) { + t.Helper() + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + for name, content := range files { + header := &tar.Header{Name: name, Mode: 0755, Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buffer.Bytes(), 0644); err != nil { + t.Fatal(err) + } +} + +func readTarGz(t *testing.T, path string) map[string]string { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + gzipReader, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + files := map[string]string{} + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + return files + } + if err != nil { + t.Fatal(err) + } + content, err := io.ReadAll(tarReader) + if err != nil { + t.Fatal(err) + } + files[header.Name] = string(content) + } +} + +func TestGenerateGoFileEmbedsRuntimeWrapperPair(t *testing.T) { dir := t.TempDir() binaryPath := filepath.Join(dir, "copilot.zst") runtimePath := filepath.Join(dir, "runtime.node.zst") + wrapperPath := filepath.Join(dir, "copilot-runtime.zst") + assetsPath := filepath.Join(dir, "runtime-assets.tgz") muslBinaryPath := filepath.Join(dir, "copilot-musl.zst") muslRuntimePath := filepath.Join(dir, "runtime-musl.node.zst") + muslWrapperPath := filepath.Join(dir, "copilot-runtime-musl.zst") + muslAssetsPath := filepath.Join(dir, "runtime-assets-musl.tgz") for _, path := range []string{ binaryPath, licensePathForOutput(binaryPath), runtimePath, + wrapperPath, + assetsPath, muslBinaryPath, muslRuntimePath, + muslWrapperPath, + muslAssetsPath, } { if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) @@ -36,10 +136,18 @@ func TestGenerateGoFileGatesRuntimeEmbed(t *testing.T) { hash, runtimePath, hash, + wrapperPath, + hash, + assetsPath, + hash, muslBinaryPath, hash, muslRuntimePath, hash, + muslWrapperPath, + hash, + muslAssetsPath, + hash, "main", ); err != nil { t.Fatal(err) @@ -52,8 +160,20 @@ func TestGenerateGoFileGatesRuntimeEmbed(t *testing.T) { if !strings.Contains(string(defaultSource), "//go:build !copilot_inprocess") { t.Fatal("default embed file does not exclude copilot_inprocess builds") } - if strings.Contains(string(defaultSource), "localEmbeddedCopilotRuntimeLib") { - t.Fatal("default embed file includes the native runtime") + if !strings.Contains(string(defaultSource), "localEmbeddedCopilotRuntimeExecutable") { + t.Fatal("default embed file does not include the runtime wrapper") + } + if !strings.Contains(string(defaultSource), "RuntimeNode:") { + t.Fatal("default embed file does not configure runtime.node") + } + if !strings.Contains(string(defaultSource), "RuntimeAssets:") { + t.Fatal("default embed file does not configure retained runtime assets") + } + if !strings.Contains(string(defaultSource), "localEmbeddedCopilotCLILinuxMusl") { + t.Fatal("default embed file does not include the Linux musl CLI") + } + if !strings.Contains(string(defaultSource), "localEmbeddedCopilotRuntimeLibLinuxMusl") { + t.Fatal("default embed file does not include the Linux musl runtime") } if _, err := parser.ParseFile(token.NewFileSet(), "zcopilot_linux_amd64.go", defaultSource, parser.AllErrors); err != nil { t.Fatalf("default generated source is invalid: %v", err) diff --git a/go/inprocess_disabled.go b/go/inprocess_disabled.go index d0626a74d3..c100e3db6a 100644 --- a/go/inprocess_disabled.go +++ b/go/inprocess_disabled.go @@ -8,6 +8,6 @@ const inProcessAvailable = false var errInProcessUnavailable = errors.New("in-process transport unavailable") -func createInProcessHost(string, inProcessHostConfig) (inProcessHost, error) { +func createInProcessHost(string, string, inProcessHostConfig) (inProcessHost, error) { return nil, errInProcessUnavailable } diff --git a/go/inprocess_enabled.go b/go/inprocess_enabled.go index c20013d8ab..2c30d5f863 100644 --- a/go/inprocess_enabled.go +++ b/go/inprocess_enabled.go @@ -6,6 +6,6 @@ import "github.com/github/copilot-sdk/go/internal/ffihost" const inProcessAvailable = true -func createInProcessHost(runtimePath string, config inProcessHostConfig) (inProcessHost, error) { - return ffihost.Create(runtimePath, config.Environment, config.Args) +func createInProcessHost(runtimePath, cliEntrypoint string, config inProcessHostConfig) (inProcessHost, error) { + return ffihost.Create(runtimePath, cliEntrypoint, config.Environment, config.Args) } diff --git a/go/internal/e2e/inprocess_ffi_e2e_test.go b/go/internal/e2e/inprocess_ffi_e2e_test.go index 6923384a28..7f7dcc3f20 100644 --- a/go/internal/e2e/inprocess_ffi_e2e_test.go +++ b/go/internal/e2e/inprocess_ffi_e2e_test.go @@ -8,8 +8,8 @@ import ( ) // TestInProcessFfiE2E is a smoke test for the in-process (FFI) transport. It -// starts a client that loads the native runtime cdylib next to the resolved CLI -// entrypoint, lets the native host spawn the worker, performs a purely local +// starts a client that loads the native runtime cdylib directly, lets the native +// host construct the server, performs a purely local // "ping" round-trip through the runtime, and stops cleanly. No auth or replay // proxy is involved, so it needs no snapshot. // diff --git a/go/internal/e2e/pending_work_resume_e2e_test.go b/go/internal/e2e/pending_work_resume_e2e_test.go index 00419aec56..58de8644ac 100644 --- a/go/internal/e2e/pending_work_resume_e2e_test.go +++ b/go/internal/e2e/pending_work_resume_e2e_test.go @@ -468,7 +468,32 @@ func TestPendingWorkResumeE2E(t *testing.T) { } if scenario.disconnectOriginalClient { + lockObserver := ctx.NewClient(func(opts *copilot.ClientOptions) { + stdio := opts.Connection.(copilot.StdioConnection) + opts.Connection = copilot.TCPConnection{Path: stdio.Path} + }) + t.Cleanup(func() { lockObserver.ForceStop() }) + if err := lockObserver.Start(t.Context()); err != nil { + t.Fatalf("Failed to start session lock observer: %v", err) + } + + waitForRPCCondition(t, pendingWorkTimeout, "session lock to be held before disconnect", func() (bool, error) { + result, err := lockObserver.RPC.Sessions.CheckInUse( + t.Context(), + &rpc.SessionsCheckInUseRequest{SessionIDs: []string{sessionID}}, + ) + return err == nil && containsString(result.InUse, sessionID), err + }) + suspendedClient.ForceStop() + + waitForRPCCondition(t, pendingWorkTimeout, "session lock to be released after disconnect", func() (bool, error) { + result, err := lockObserver.RPC.Sessions.CheckInUse( + t.Context(), + &rpc.SessionsCheckInUseRequest{SessionIDs: []string{sessionID}}, + ) + return err == nil && !containsString(result.InUse, sessionID), err + }) } resumedClient := ctx.NewClient(func(opts *copilot.ClientOptions) { diff --git a/go/internal/e2e/rewind_e2e_test.go b/go/internal/e2e/rewind_e2e_test.go index 5fc29a13e8..ab73feadda 100644 --- a/go/internal/e2e/rewind_e2e_test.go +++ b/go/internal/e2e/rewind_e2e_test.go @@ -14,8 +14,10 @@ import ( ) const ( - rewindFileName = "rewind-sdk.txt" - rewindFileContent = "SDK rewind content" + rewindFileName = "rewind-sdk.txt" + rewindFileOriginalContent = "Original rewind content" + rewindFilePreparedContent = "Prepared rewind content" + rewindFileContent = "SDK rewind content" ) func TestRewindE2E(t *testing.T) { @@ -24,12 +26,11 @@ func TestRewindE2E(t *testing.T) { t.Cleanup(func() { client.ForceStop() }) t.Run("should restore tracked file and conversation", func(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("blocked on CLI 1.0.81 file-change tracking regression on Windows") - } - ctx.ConfigureForTest(t) filePath := filepath.Join(ctx.WorkDir, rewindFileName) + if err := os.WriteFile(filePath, []byte(rewindFileOriginalContent), 0o600); err != nil { + t.Fatalf("Failed to create original file: %v", err) + } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ Model: "claude-sonnet-4.5", EnableFileChangeTracking: copilot.Bool(true), @@ -40,9 +41,30 @@ func TestRewindE2E(t *testing.T) { } defer session.Disconnect() + ready, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the edit tool to replace the exact contents of " + rewindFileName + " from " + + rewindFileOriginalContent + " to " + rewindFilePreparedContent + + ". After the tool succeeds, reply with exactly SDK_REWIND_READY.", + }) + if err != nil { + t.Fatalf("SendAndWait readiness turn failed: %v", err) + } + readyData, ok := ready.Data.(*copilot.AssistantMessageData) + if !ok || readyData.Content != "SDK_REWIND_READY" { + t.Fatalf("Expected SDK_REWIND_READY response, got %+v", ready) + } + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read prepared file: %v", err) + } + if string(content) != rewindFilePreparedContent { + t.Fatalf("Expected file content %q, got %q", rewindFilePreparedContent, content) + } + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ - Prompt: "Use the create tool to create " + rewindFileName + " containing exactly " + - rewindFileContent + ". After the tool succeeds, reply with exactly SDK_REWIND_DONE.", + Prompt: "Use the edit tool to replace the exact contents of " + rewindFileName + " from " + + rewindFilePreparedContent + " to " + rewindFileContent + + ". After the tool succeeds, reply with exactly SDK_REWIND_DONE.", }) if err != nil { t.Fatalf("SendAndWait failed: %v", err) @@ -51,9 +73,9 @@ func TestRewindE2E(t *testing.T) { if !ok || responseData.Content != "SDK_REWIND_DONE" { t.Fatalf("Expected SDK_REWIND_DONE response, got %+v", response) } - content, err := os.ReadFile(filePath) + content, err = os.ReadFile(filePath) if err != nil { - t.Fatalf("Failed to read created file: %v", err) + t.Fatalf("Failed to read updated file: %v", err) } if string(content) != rewindFileContent { t.Fatalf("Expected file content %q, got %q", rewindFileContent, content) @@ -63,10 +85,13 @@ func TestRewindE2E(t *testing.T) { if !rewindPoints.FileChangeTrackingEnabled { t.Fatal("Expected file change tracking to be enabled") } - if len(rewindPoints.Points) != 1 { - t.Fatalf("Expected one rewind point, got %+v", rewindPoints.Points) + if len(rewindPoints.Points) != 2 { + t.Fatalf("Expected two rewind points, got %+v", rewindPoints.Points) + } + rewindPoint := rewindPoints.Points[1] + if !rewindPoint.TurnChangedFiles { + t.Fatalf("Expected the edit turn to report changed files, got %+v", rewindPoint) } - rewindPoint := rewindPoints.Points[0] if !rewindPoint.CanRestoreFiles || rewindPoint.FileCount != 1 { t.Fatalf("Expected one restorable file, got %+v", rewindPoint) } @@ -99,8 +124,12 @@ func TestRewindE2E(t *testing.T) { t.Fatalf("Expected one restored file, got %+v", rewind.RestoredFiles) } assertSameRewindPath(t, filePath, rewind.RestoredFiles[0]) - if _, err := os.Stat(filePath); !os.IsNotExist(err) { - t.Fatalf("Expected rewound file to be removed, stat error: %v", err) + content, err = os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read restored file: %v", err) + } + if string(content) != rewindFilePreparedContent { + t.Fatalf("Expected restored file content %q, got %q", rewindFilePreparedContent, content) } events, err := session.GetEvents(t.Context()) @@ -124,9 +153,10 @@ func waitForRewindPoints(t *testing.T, session *copilot.Session) *rpc.HistoryLis t.Fatalf("ListRewindPoints failed: %v", err) } if result.UnavailableReason == nil && - len(result.Points) == 1 && - result.Points[0].CanRestoreFiles && - result.Points[0].FileCount == 1 { + len(result.Points) == 2 && + result.Points[1].TurnChangedFiles && + result.Points[1].CanRestoreFiles && + result.Points[1].FileCount == 1 { return result } if time.Now().After(deadline) { diff --git a/go/internal/embeddedcli/embeddedcli.go b/go/internal/embeddedcli/embeddedcli.go index cd0be21895..2535cf5f20 100644 --- a/go/internal/embeddedcli/embeddedcli.go +++ b/go/internal/embeddedcli/embeddedcli.go @@ -1,7 +1,9 @@ package embeddedcli import ( + "archive/tar" "bytes" + "compress/gzip" "crypto/sha256" "fmt" "io" @@ -23,10 +25,10 @@ import ( // version-specific child directory so multiple versions can coexist. License, // when provided, is written next to the installed binary. // -// RuntimeLib and RuntimeLibHash are optional: when set, the native in-process -// runtime library (cdylib) is installed next to the CLI binary so the in-process -// (FFI) transport can load it. They are omitted for CLI packages that do not -// ship the native runtime. +// RuntimeExecutable and RuntimeNode form the adjacent out-of-process runtime +// pair. RuntimeAssets is a filtered npm package archive containing auxiliary +// binaries and resources. RuntimeLib is the same cdylib bytes installed under +// the natural platform name for the optional in-process transport. type Config struct { Cli io.Reader CliHash []byte @@ -36,12 +38,25 @@ type Config struct { RuntimeLib io.Reader RuntimeLibHash []byte + RuntimeExecutable io.Reader + RuntimeExecutableHash []byte + RuntimeNode io.Reader + RuntimeNodeHash []byte + RuntimeAssets io.Reader + RuntimeAssetsHash []byte + // LinuxMuslCli and LinuxMuslRuntimeLib are optional alternatives selected // automatically when the application runs on a musl-based Linux system. - LinuxMuslCli io.Reader - LinuxMuslCliHash []byte - LinuxMuslRuntimeLib io.Reader - LinuxMuslRuntimeLibHash []byte + LinuxMuslCli io.Reader + LinuxMuslCliHash []byte + LinuxMuslRuntimeLib io.Reader + LinuxMuslRuntimeLibHash []byte + LinuxMuslRuntimeExecutable io.Reader + LinuxMuslRuntimeExecutableHash []byte + LinuxMuslRuntimeNode io.Reader + LinuxMuslRuntimeNodeHash []byte + LinuxMuslRuntimeAssets io.Reader + LinuxMuslRuntimeAssetsHash []byte Dir string Version string @@ -60,6 +75,10 @@ func Setup(cfg Config) { if cfg.LinuxMuslRuntimeLib != nil && len(cfg.LinuxMuslRuntimeLibHash) != sha256.Size { panic(fmt.Sprintf("LinuxMuslRuntimeLibHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(cfg.LinuxMuslRuntimeLibHash))) } + validateRuntimePairConfig(cfg.RuntimeExecutable, cfg.RuntimeExecutableHash, cfg.RuntimeNode, cfg.RuntimeNodeHash, "") + validateRuntimePairConfig(cfg.LinuxMuslRuntimeExecutable, cfg.LinuxMuslRuntimeExecutableHash, cfg.LinuxMuslRuntimeNode, cfg.LinuxMuslRuntimeNodeHash, "LinuxMusl") + validateOptionalHash(cfg.RuntimeAssets, cfg.RuntimeAssetsHash, "RuntimeAssetsHash") + validateOptionalHash(cfg.LinuxMuslRuntimeAssets, cfg.LinuxMuslRuntimeAssetsHash, "LinuxMuslRuntimeAssetsHash") setupMu.Lock() defer setupMu.Unlock() if setupDone { @@ -93,13 +112,34 @@ func RuntimeLibPath() string { return runtimeLibPath } +// RuntimePath returns the installed copilot-runtime executable, or "" when the +// application bundle predates the out-of-process runtime pair. +func RuntimePath() string { + setupMu.Lock() + defer setupMu.Unlock() + if !setupDone { + return "" + } + pathInitialized = true + selectLinuxMuslBundle() + if config.RuntimeExecutable == nil { + return "" + } + if runtimePath == "" { + runtimePath = installRuntime() + } + return runtimePath +} + var ( - config Config - setupMu sync.Mutex - setupDone bool - pathInitialized bool - runtimeLibPath string - linuxMuslBundle bool + config Config + setupMu sync.Mutex + setupDone bool + pathInitialized bool + runtimeLibPath string + runtimePath string + runtimeAssetsInstalled bool + linuxMuslBundle bool ) func install() (path string) { @@ -118,6 +158,38 @@ func install() (path string) { fmt.Printf("installing embedded CLI at %s installation took %s\n", path, duration) }() } + installDir := configuredInstallDir() + path, err := installAt(installDir) + if err != nil { + logError("installing in configured directory", err) + return "" + } + return path +} + +func installRuntime() (path string) { + verbose := os.Getenv("COPILOT_CLI_INSTALL_VERBOSE") == "1" + logError := func(msg string, err error) { + if verbose { + fmt.Printf("embedded runtime installation error: %s: %v\n", msg, err) + } + } + if verbose { + start := time.Now() + defer func() { + fmt.Printf("installing embedded runtime at %s took %s\n", path, time.Since(start)) + }() + } + + path, err := installRuntimeAt(configuredInstallDir()) + if err != nil { + logError("installing in configured directory", err) + return "" + } + return path +} + +func configuredInstallDir() string { installDir := config.Dir if installDir == "" { if copilotHome := os.Getenv("COPILOT_HOME"); copilotHome != "" { @@ -131,12 +203,7 @@ func install() (path string) { installDir = filepath.Join(installDir, "copilot-sdk") } } - path, err := installAt(installDir) - if err != nil { - logError("installing in configured directory", err) - return "" - } - return path + return installDir } func selectLinuxMuslBundle() { @@ -152,6 +219,12 @@ func linuxMuslConfig(cfg Config) Config { cfg.CliHash = cfg.LinuxMuslCliHash cfg.RuntimeLib = cfg.LinuxMuslRuntimeLib cfg.RuntimeLibHash = cfg.LinuxMuslRuntimeLibHash + cfg.RuntimeExecutable = cfg.LinuxMuslRuntimeExecutable + cfg.RuntimeExecutableHash = cfg.LinuxMuslRuntimeExecutableHash + cfg.RuntimeNode = cfg.LinuxMuslRuntimeNode + cfg.RuntimeNodeHash = cfg.LinuxMuslRuntimeNodeHash + cfg.RuntimeAssets = cfg.LinuxMuslRuntimeAssets + cfg.RuntimeAssetsHash = cfg.LinuxMuslRuntimeAssetsHash return cfg } @@ -198,6 +271,9 @@ func installAt(installDir string) (string, error) { } runtimeLibPath = libPath } + if err := installRuntimeAssets(installDir); err != nil { + return "", err + } return finalPath, nil } @@ -229,12 +305,188 @@ func installAt(installDir string) (string, error) { if err != nil { return "", err } + if err := installRuntimeAssets(installDir); err != nil { + return "", err + } runtimeLibPath = libPath } return finalPath, nil } +func installRuntimeAt(installDir string) (string, error) { + version := sanitizeVersion(config.Version) + if version != "" { + installDir = filepath.Join(installDir, version) + } + if linuxMuslBundle { + installDir = filepath.Join(installDir, "linuxmusl") + } + if err := os.MkdirAll(installDir, 0755); err != nil { + return "", fmt.Errorf("creating install directory: %w", err) + } + + if release, _ := flock.Acquire(filepath.Join(installDir, ".copilot-cli.lock")); release != nil { + defer release() + } + path, err := installRuntimePair(installDir) + if err != nil { + return "", err + } + if err := installRuntimeAssets(installDir); err != nil { + return "", err + } + return path, nil +} + +func validateOptionalHash(reader io.Reader, hash []byte, name string) { + if reader != nil && len(hash) != sha256.Size { + panic(fmt.Sprintf("%s must be a SHA-256 hash (%d bytes), got %d bytes", name, sha256.Size, len(hash))) + } +} + +func installRuntimeAssets(installDir string) error { + if config.RuntimeAssets == nil || runtimeAssetsInstalled { + return nil + } + archiveBytes, err := io.ReadAll(config.RuntimeAssets) + if closer, ok := config.RuntimeAssets.(io.Closer); ok { + closer.Close() + } + if err != nil { + return fmt.Errorf("reading runtime assets: %w", err) + } + actual := sha256.Sum256(archiveBytes) + if !bytes.Equal(actual[:], config.RuntimeAssetsHash) { + return fmt.Errorf("runtime assets hash mismatch") + } + gzipReader, err := gzip.NewReader(bytes.NewReader(archiveBytes)) + if err != nil { + return fmt.Errorf("opening runtime assets: %w", err) + } + defer gzipReader.Close() + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading runtime assets: %w", err) + } + if header.Typeflag != tar.TypeReg { + continue + } + clean := filepath.Clean(filepath.FromSlash(header.Name)) + if !filepath.IsLocal(clean) { + return fmt.Errorf("unsafe runtime asset path %q", header.Name) + } + content, err := io.ReadAll(tarReader) + if err != nil { + return fmt.Errorf("reading runtime asset %q: %w", header.Name, err) + } + path := filepath.Join(installDir, clean) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating runtime asset directory: %w", err) + } + hash := sha256.Sum256(content) + mode := os.FileMode(header.Mode & 0777) + if err := installVerifiedFile(path, bytes.NewReader(content), hash[:], mode, "runtime asset"); err != nil { + return err + } + } + runtimeAssetsInstalled = true + return nil +} + +func validateRuntimePairConfig(wrapper io.Reader, wrapperHash []byte, node io.Reader, nodeHash []byte, prefix string) { + if (wrapper == nil) != (node == nil) { + panic(prefix + "RuntimeExecutable and " + prefix + "RuntimeNode must be provided together") + } + if wrapper == nil { + return + } + if len(wrapperHash) != sha256.Size { + panic(fmt.Sprintf("%sRuntimeExecutableHash must be a SHA-256 hash (%d bytes), got %d bytes", prefix, sha256.Size, len(wrapperHash))) + } + if len(nodeHash) != sha256.Size { + panic(fmt.Sprintf("%sRuntimeNodeHash must be a SHA-256 hash (%d bytes), got %d bytes", prefix, sha256.Size, len(nodeHash))) + } +} + +func installRuntimePair(installDir string) (string, error) { + nodePath := filepath.Join(installDir, "runtime.node") + if err := installVerifiedFile(nodePath, config.RuntimeNode, config.RuntimeNodeHash, 0644, "runtime.node"); err != nil { + return "", err + } + wrapperPath := filepath.Join(installDir, runtimeExecutableName()) + if err := installVerifiedFile(wrapperPath, config.RuntimeExecutable, config.RuntimeExecutableHash, 0755, "runtime wrapper"); err != nil { + return "", err + } + return wrapperPath, nil +} + +func installVerifiedFile(path string, reader io.Reader, expectedHash []byte, mode os.FileMode, label string) error { + if _, err := os.Stat(path); err == nil { + existingHash, err := hashFile(path) + if err != nil { + return fmt.Errorf("hashing existing %s: %w", label, err) + } + if !bytes.Equal(existingHash, expectedHash) { + return fmt.Errorf("existing %s hash mismatch", label) + } + if runtime.GOOS != "windows" && mode.Perm()&0111 != 0 { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("checking existing %s permissions: %w", label, err) + } + if info.Mode().Perm()&0111 == 0 { + if err := os.Chmod(path, info.Mode().Perm()|mode.Perm()&0111); err != nil { + return fmt.Errorf("restoring existing %s permissions: %w", label, err) + } + } + } + return nil + } + + tmp, err := os.CreateTemp(filepath.Dir(path), ".copilot-runtime-pair-*.tmp") + if err != nil { + return fmt.Errorf("creating temporary %s: %w", label, err) + } + tmpPath := tmp.Name() + h := sha256.New() + _, err = io.Copy(io.MultiWriter(tmp, h), reader) + if err1 := tmp.Chmod(mode); err1 != nil && err == nil { + err = err1 + } + if err1 := tmp.Close(); err1 != nil && err == nil { + err = err1 + } + if closer, ok := reader.(io.Closer); ok { + closer.Close() + } + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("writing %s: %w", label, err) + } + if !bytes.Equal(h.Sum(nil), expectedHash) { + os.Remove(tmpPath) + return fmt.Errorf("%s hash mismatch", label) + } + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("installing %s: %w", label, err) + } + return nil +} + +func runtimeExecutableName() string { + if runtime.GOOS == "windows" { + return "copilot-runtime.exe" + } + return "copilot-runtime" +} + // installRuntimeLib writes the embedded runtime cdylib into installDir under its // natural platform file name, verifying its SHA-256. It is idempotent: an // existing file with a matching hash is reused; a mismatch is a hard error. diff --git a/go/internal/embeddedcli/embeddedcli_test.go b/go/internal/embeddedcli/embeddedcli_test.go index b0394e0f69..159b6e1505 100644 --- a/go/internal/embeddedcli/embeddedcli_test.go +++ b/go/internal/embeddedcli/embeddedcli_test.go @@ -1,7 +1,9 @@ package embeddedcli import ( + "archive/tar" "bytes" + "compress/gzip" "crypto/sha256" "os" "path/filepath" @@ -17,9 +19,143 @@ func resetGlobals() { setupDone = false pathInitialized = false runtimeLibPath = "" + runtimePath = "" + runtimeAssetsInstalled = false linuxMuslBundle = false } +func TestInstallRuntimeWritesRetainedAssets(t *testing.T) { + resetGlobals() + tempDir := t.TempDir() + cli := []byte("cli") + wrapper := []byte("wrapper") + node := []byte("runtime") + assets := runtimeAssetsArchive(t, map[string]assetFixture{ + "ripgrep/bin/test-platform/rg": {content: []byte("ripgrep"), mode: 0755}, + "definitions/future.json": {content: []byte("{}"), mode: 0644}, + }) + cliHash := sha256.Sum256(cli) + wrapperHash := sha256.Sum256(wrapper) + nodeHash := sha256.Sum256(node) + assetsHash := sha256.Sum256(assets) + Setup(Config{ + Cli: bytes.NewReader(cli), + CliHash: cliHash[:], + RuntimeExecutable: bytes.NewReader(wrapper), + RuntimeExecutableHash: wrapperHash[:], + RuntimeNode: bytes.NewReader(node), + RuntimeNodeHash: nodeHash[:], + RuntimeAssets: bytes.NewReader(assets), + RuntimeAssetsHash: assetsHash[:], + Version: "1.2.3", + Dir: tempDir, + }) + + gotWrapper, err := installRuntimeAt(tempDir) + if err != nil { + t.Fatal(err) + } + installDir := filepath.Dir(gotWrapper) + if got, err := os.ReadFile(filepath.Join(installDir, "ripgrep", "bin", "test-platform", "rg")); err != nil || string(got) != "ripgrep" { + t.Fatalf("ripgrep content=%q err=%v", got, err) + } + if got, err := os.ReadFile(filepath.Join(installDir, "definitions", "future.json")); err != nil || string(got) != "{}" { + t.Fatalf("definition content=%q err=%v", got, err) + } +} + +type assetFixture struct { + content []byte + mode int64 +} + +func runtimeAssetsArchive(t *testing.T, files map[string]assetFixture) []byte { + t.Helper() + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + for name, fixture := range files { + header := &tar.Header{Name: name, Mode: fixture.mode, Size: int64(len(fixture.content)), Typeflag: tar.TypeReg} + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write(fixture.content); err != nil { + t.Fatal(err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func TestInstallRuntimeWritesAdjacentPairWithoutCLI(t *testing.T) { + resetGlobals() + tempDir := t.TempDir() + cli := []byte("cli") + wrapper := []byte("wrapper") + node := []byte("runtime") + cliHash := sha256.Sum256(cli) + wrapperHash := sha256.Sum256(wrapper) + nodeHash := sha256.Sum256(node) + Setup(Config{ + Cli: bytes.NewReader(cli), + CliHash: cliHash[:], + RuntimeExecutable: bytes.NewReader(wrapper), + RuntimeExecutableHash: wrapperHash[:], + RuntimeNode: bytes.NewReader(node), + RuntimeNodeHash: nodeHash[:], + Version: "1.2.3", + Dir: tempDir, + }) + + gotWrapper, err := installRuntimeAt(tempDir) + if err != nil { + t.Fatal(err) + } + if gotWrapper != filepath.Join(tempDir, "1.2.3", runtimeExecutableName()) { + t.Fatalf("RuntimePath() = %q", gotWrapper) + } + if got, err := os.ReadFile(filepath.Join(filepath.Dir(gotWrapper), "runtime.node")); err != nil || !bytes.Equal(got, node) { + t.Fatalf("runtime.node content=%q err=%v", got, err) + } + if cliPath := filepath.Join(filepath.Dir(gotWrapper), binaryNameForOS()); fileExists(cliPath) { + t.Fatalf("managed runtime installation unexpectedly materialized CLI host at %q", cliPath) + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func TestInstallVerifiedFileRestoresExecutablePermission(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not use Unix execute bits") + } + path := filepath.Join(t.TempDir(), "copilot-runtime") + content := []byte("wrapper") + hash := sha256.Sum256(content) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + if err := installVerifiedFile(path, bytes.NewReader(content), hash[:], 0755, "runtime wrapper"); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0111 == 0 { + t.Fatal("existing runtime wrapper was not made executable") + } +} + func mustPanic(t *testing.T, fn func()) { t.Helper() defer func() { diff --git a/go/internal/ffihost/ffihost.go b/go/internal/ffihost/ffihost.go index 30cd831281..9a824881de 100644 --- a/go/internal/ffihost/ffihost.go +++ b/go/internal/ffihost/ffihost.go @@ -159,8 +159,8 @@ type Host struct { // Create resolves the native library and prepares the host. environment and // args contain SDK-managed runtime options. -func Create(cliEntrypoint string, environment map[string]string, args []string) (*Host, error) { - libraryPath, err := ResolveLibraryPath(cliEntrypoint) +func Create(runtimeEntrypoint, cliEntrypoint string, environment map[string]string, args []string) (*Host, error) { + libraryPath, err := ResolveLibraryPath(runtimeEntrypoint) if err != nil { return nil, err } @@ -207,18 +207,13 @@ func (h *Host) Start() error { runtime.KeepAlive(argv) runtime.KeepAlive(env) if h.serverID == 0 { - return fmt.Errorf("copilot_runtime_host_start failed (library %q, entrypoint %q)", h.libraryPath, h.cliEntrypoint) + return fmt.Errorf("copilot_runtime_host_start failed (library %q)", h.libraryPath) } - // host_start spawned the worker child via libuv's uv_spawn, which installs a - // SIGCHLD handler without SA_ONSTACK on its first call. The Go runtime aborts - // ("non-Go code set up signal handler without SA_ONSTACK flag") when it later - // reaps one of its own os/exec children (e.g. a test-spawned MCP server) and - // the delivered SIGCHLD lands on a non-signal stack. Re-add SA_ONSTACK to that - // foreign handler now that it exists (implemented on darwin+linux; a no-op on - // other platforms, and before the first spawn there is nothing to fix — hence - // here rather than at library load). - rearmForeignSignalHandlers(h.lib.handle) + if h.cliEntrypoint != "" { + // A legacy embedded host may install a SIGCHLD handler without SA_ONSTACK. + rearmForeignSignalHandlers(h.lib.handle) + } callbackHandle := sharedOutboundCallback() callbackToken := uintptr(nextOutboundToken.Add(1)) @@ -229,7 +224,9 @@ func (h *Host) Start() error { outboundTargets.Delete(callbackToken) h.callbackToken = 0 h.lib.hostShutdown(h.serverID) - rearmForeignSignalHandlers(h.lib.handle) + if h.cliEntrypoint != "" { + rearmForeignSignalHandlers(h.lib.handle) + } h.serverID = 0 return fmt.Errorf("copilot_runtime_connection_open failed") } @@ -243,14 +240,12 @@ func (h *Host) Writer() io.WriteCloser { return hostWriter{h} } func (h *Host) Reader() io.ReadCloser { return h.recv } func (h *Host) buildArgv() []byte { - // A `.js` entrypoint (dev) is launched via node; the packaged single-file CLI - // embeds its own Node and is invoked directly. `--no-auto-update` pins the - // worker to the runtime package matching the loaded cdylib (avoids ABI skew). - var argv []string - if strings.HasSuffix(strings.ToLower(h.cliEntrypoint), ".js") { - argv = []string{"node", h.cliEntrypoint, "--embedded-host", "--no-auto-update"} - } else { - argv = []string{h.cliEntrypoint, "--embedded-host", "--no-auto-update"} + argv := make([]string, 0, len(h.args)+4) + if h.cliEntrypoint != "" { + if strings.HasSuffix(strings.ToLower(h.cliEntrypoint), ".js") { + argv = append(argv, "node") + } + argv = append(argv, h.cliEntrypoint, "--embedded-host", "--no-auto-update") } argv = append(argv, h.args...) b, _ := json.Marshal(argv) @@ -365,10 +360,10 @@ func (h *Host) Dispose() { } if serverID != 0 { h.lib.hostShutdown(serverID) - // libuv may restore a previously saved SIGCHLD action while tearing down - // its final child watcher, so repair the process-wide handler again after - // shutdown before Go reaps another os/exec child. - rearmForeignSignalHandlers(h.lib.handle) + if h.cliEntrypoint != "" { + // A legacy host may restore its saved SIGCHLD action during shutdown. + rearmForeignSignalHandlers(h.lib.handle) + } } h.recv.Close() } diff --git a/go/internal/ffihost/ffihost_test.go b/go/internal/ffihost/ffihost_test.go index bc588fa6a9..ccb48af419 100644 --- a/go/internal/ffihost/ffihost_test.go +++ b/go/internal/ffihost/ffihost_test.go @@ -25,10 +25,9 @@ func TestDisposeUnregistersOutboundTarget(t *testing.T) { } } -func TestBuildArgvAppendsManagedOptions(t *testing.T) { +func TestBuildArgvWithoutEntrypointContainsOnlyManagedOptions(t *testing.T) { host := &Host{ - cliEntrypoint: "copilot", - args: []string{"--log-level", "debug", "--remote"}, + args: []string{"--log-level", "debug", "--remote"}, } var argv []string @@ -36,7 +35,45 @@ func TestBuildArgvAppendsManagedOptions(t *testing.T) { t.Fatal(err) } - expected := []string{"copilot", "--embedded-host", "--no-auto-update", "--log-level", "debug", "--remote"} + expected := []string{"--log-level", "debug", "--remote"} + if len(argv) != len(expected) { + t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(argv), argv) + } + for i := range expected { + if argv[i] != expected[i] { + t.Fatalf("Expected argument %d to be %q, got %q", i, expected[i], argv[i]) + } + } +} + +func TestBuildArgvPreservesExplicitEntrypoint(t *testing.T) { + host := &Host{cliEntrypoint: "copilot", args: []string{"--remote"}} + + var argv []string + if err := json.Unmarshal(host.buildArgv(), &argv); err != nil { + t.Fatal(err) + } + + expected := []string{"copilot", "--embedded-host", "--no-auto-update", "--remote"} + if len(argv) != len(expected) { + t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(argv), argv) + } + for i := range expected { + if argv[i] != expected[i] { + t.Fatalf("Expected argument %d to be %q, got %q", i, expected[i], argv[i]) + } + } +} + +func TestBuildArgvUsesNodeForExplicitJavaScriptEntrypoint(t *testing.T) { + host := &Host{cliEntrypoint: "copilot.js"} + + var argv []string + if err := json.Unmarshal(host.buildArgv(), &argv); err != nil { + t.Fatal(err) + } + + expected := []string{"node", "copilot.js", "--embedded-host", "--no-auto-update"} if len(argv) != len(expected) { t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(argv), argv) } diff --git a/go/internal/ffihost/resolve.go b/go/internal/ffihost/resolve.go index c8d4052322..5205a01c19 100644 --- a/go/internal/ffihost/resolve.go +++ b/go/internal/ffihost/resolve.go @@ -63,7 +63,8 @@ func PrebuildsFolder() string { // entrypoint. It checks, in order: // // 1. The natural platform library name next to the CLI (bundled/flat layout). -// 2. prebuilds//runtime.node next to the CLI (dev/package layout). +// 2. runtime.node next to the CLI (out-of-process wrapper layout). +// 3. prebuilds//runtime.node next to the CLI (dev/package layout). // // It returns an error when neither exists. func ResolveLibraryPath(cliEntrypoint string) (string, error) { @@ -78,6 +79,11 @@ func ResolveLibraryPath(cliEntrypoint string) (string, error) { return flat, nil } + adjacent := filepath.Join(dir, "runtime.node") + if fileExists(adjacent) { + return adjacent, nil + } + if folder := PrebuildsFolder(); folder != "" { prebuilt := filepath.Join(dir, "prebuilds", folder, "runtime.node") if fileExists(prebuilt) { @@ -86,7 +92,7 @@ func ResolveLibraryPath(cliEntrypoint string) (string, error) { } return "", fmt.Errorf( - "in-process FFI runtime library not found next to %q (looked for %q and prebuilds/%s/runtime.node); "+ + "in-process FFI runtime library not found next to %q (looked for %q, runtime.node, and prebuilds/%s/runtime.node); "+ "use a runtime package that ships the native library", abs, NaturalLibraryName(), PrebuildsFolder()) } diff --git a/java/README.md b/java/README.md index fb4aeb9d25..77b68bdf75 100644 --- a/java/README.md +++ b/java/README.md @@ -20,7 +20,11 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A To use the SDK, you'll need: - Java 17 or later. **JDK 25 recommended**. The distributed jar is a multi-release jar (MR-JAR) and is compiled on JDK 25 with `maven.compiler.release` set to 17. This means, when run on JDK 25 and later, the SDK automatically uses virtual threads for its default internal executor. -- GitHub Copilot CLI 1.0.55-5 or later installed and in `PATH` (or provide custom `cliPath`) + +Managed stdio and TCP connections materialize the platform classifier's +`copilot-runtime[.exe]` and adjacent `runtime.node` by default. An explicit +`cliPath` or `COPILOT_CLI_PATH` environment variable overrides the bundled +runtime. ## Installation @@ -186,9 +190,11 @@ For rotating per-session GitHub credentials, use `ResumeSessionConfig` setter) instead of `setGitHubToken(...)`: ```java -var config = new SessionConfig().setGitHubTokenProvider(args -> - acquireForHost(args.host()).thenApply(token -> - GitHubTokenProviderResult.token(token, 8 * 60 * 60))); +var config = new SessionConfig() + .setGitHubTokenProvider(args -> + acquireForHost(args.host()).thenApply(token -> + GitHubTokenProviderResult.token(token, 8 * 60 * 60))) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL); ``` The remaining lifetime is required and must be positive when the callback @@ -569,7 +575,7 @@ mvn clean verify -Dcopilot.native.libc=glibc mvn clean package -pl copilot-native -DskipTests -Dcopilot.native.libc=glibc -Dcopilot.native.skip.download=true ``` -On Linux, the classifier JAR contains `runtime.node`, `platform.properties`, and `copilot` under `native/linux-x64` or `native/linux-arm64`. On Windows, it contains those resources under `native/win32-x64` or `native/win32-arm64`, with the CLI named `copilot.exe`. On Apple Silicon macOS, it contains them under `native/darwin-arm64`. The placeholder JAR remains OS-neutral and contains no native binaries. Unsupported hosts retain the placeholder-only behavior. +Each classifier JAR includes `runtime.node`, `platform.properties`, and `copilot-runtime` (or `copilot-runtime.exe`) under its `native/` directory. It does not contain the legacy `copilot` SEA. The placeholder JAR remains OS-neutral and contains no native binaries. Unsupported hosts retain the placeholder-only behavior. ## License diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml index a0272acdc4..21629a33f9 100644 --- a/java/copilot-native/pom.xml +++ b/java/copilot-native/pom.xml @@ -126,7 +126,7 @@ @@ -203,12 +203,15 @@ - + + + + - + - + @@ -243,7 +246,6 @@ inprocess linux-x64 - copilot @@ -306,7 +308,6 @@ linux-x64 - copilot @@ -420,7 +421,6 @@ win32-x64 - copilot.exe @@ -530,7 +530,6 @@ darwin-arm64 - copilot diff --git a/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs b/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs index 095d8c1e9e..08eccb644a 100644 --- a/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs +++ b/java/copilot-native/scripts/create-native-classifier-test-fixture.mjs @@ -13,14 +13,14 @@ export function createNativeClassifierTestFixture({ outputPath, repoRoot, }) { - const cliFilename = classifier.startsWith("win32") - ? "copilot.exe" - : "copilot"; + const runtimeFilename = classifier.startsWith("win32") + ? "copilot-runtime.exe" + : "copilot-runtime"; const nativeVersion = readPinnedNativeVersion(repoRoot, classifier); const prefix = `native/${classifier}`; writeStoredZip(outputPath, [ [`${prefix}/runtime.node`, "test runtime"], - [`${prefix}/${cliFilename}`, "test cli"], + [`${prefix}/${runtimeFilename}`, "test runtime wrapper"], [ `${prefix}/platform.properties`, `classifier=${classifier}\nversion=${nativeVersion}\n`, diff --git a/java/copilot-native/scripts/fetch-native.mjs b/java/copilot-native/scripts/fetch-native.mjs index 7b68f04065..4ee91b6aa6 100644 --- a/java/copilot-native/scripts/fetch-native.mjs +++ b/java/copilot-native/scripts/fetch-native.mjs @@ -3,18 +3,16 @@ *--------------------------------------------------------------------------------------------*/ /** - * Downloads the `runtime.node` native binary for a single platform classifier - * and stages it for packaging into a classifier JAR. + * Downloads the native runtime artifacts for one platform classifier. * * Steps: * 1. Read the pinned version and the SHA-512 `integrity` value for * `@github/copilot-` from `nodejs/package-lock.json`. * 2. `npm pack` that exact version into the staging directory. * 3. Verify the downloaded tarball against the `integrity` value. - * 4. Extract `package/prebuilds//runtime.node` to - * `//native//runtime.node`. - * 5. Extract `package/copilot` (or `package/copilot.exe` on Windows) to - * `//native//copilot`. + * 4. Stage the hostless runtime tree, flattening the selected prebuild directory + * beside the package's retained top-level runtime assets. + * 5. Write an inventory consumed by the SDK's generic classpath extractor. * 6. Write `//native//platform.properties`. * * Usage: node fetch-native.mjs @@ -25,6 +23,28 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +const excludedTopLevel = new Set([ + 'app.js', + 'assets', + 'changelog.json', + 'copilot', + 'copilot.exe', + 'copilot-sdk', + 'foundry-local-sdk', + 'index.js', + 'LICENSE.md', + 'napi-oop-runtime', + 'npm-loader.js', + 'package.json', + 'preloads', + 'pvrecorder', + 'queries', + 'README.md', + 'sdk', + 'sea-loader.js', + 'webview', +]); + const [repoRoot, stagingDir, classifier] = process.argv.slice(2); if (!repoRoot || !stagingDir || !classifier) { @@ -52,34 +72,35 @@ const outDir = path.join(stagingDir, classifier); const resourceDir = path.join(outDir, 'native', classifier); const runtimePath = path.join(resourceDir, 'runtime.node'); const isWindows = classifier.startsWith('win32'); -const cliTarballMember = isWindows ? 'package/copilot.exe' : 'package/copilot'; -const cliFilename = isWindows ? 'copilot.exe' : 'copilot'; -const cliPath = path.join(resourceDir, cliFilename); +const wrapperFilename = isWindows ? 'copilot-runtime.exe' : 'copilot-runtime'; +const wrapperPath = path.join(resourceDir, wrapperFilename); +const inventoryPath = path.join(resourceDir, 'runtime-assets.list'); const platformPropertiesPath = path.join(resourceDir, 'platform.properties'); const expectedPlatformProperties = `classifier=${classifier}\nversion=${version}\n`; +const stagingSchema = 'hostless-runtime-v2'; const stampPath = path.join(outDir, '.version'); // Idempotence: skip the download only when every required staged artifact // matches the package identity recorded in the stamp. if ( fs.existsSync(runtimePath) && - fs.existsSync(cliPath) && + fs.existsSync(wrapperPath) && + fs.existsSync(inventoryPath) && fs.existsSync(platformPropertiesPath) && fs.existsSync(stampPath) ) { const stampLines = fs.readFileSync(stampPath, 'utf8').trim().split('\n'); - const stampVersion = stampLines[0] || ''; - const stampIntegrity = stampLines[1] || ''; - const stampRuntimeDigest = stampLines[2] || ''; - const stampCliDigest = stampLines[3] || ''; - const currentRuntimeDigest = digestFile(runtimePath); - const currentCliDigest = digestFile(cliPath); + const stampSchema = stampLines[0] || ''; + const stampVersion = stampLines[1] || ''; + const stampIntegrity = stampLines[2] || ''; + const stampTreeDigest = stampLines[3] || ''; + const currentTreeDigest = digestTree(resourceDir); const currentPlatformProperties = fs.readFileSync(platformPropertiesPath, 'utf8'); if ( + stampSchema === stagingSchema && stampVersion === version && stampIntegrity === integrity && - stampRuntimeDigest === currentRuntimeDigest && - stampCliDigest === currentCliDigest && + stampTreeDigest === currentTreeDigest && currentPlatformProperties === expectedPlatformProperties ) { console.log(`${packageName}@${version} already staged at ${runtimePath}`); @@ -107,28 +128,99 @@ if (actual !== integrity) { } console.log(`Integrity verified (${integrity.slice(0, 20)}...).`); -const memberPath = `package/prebuilds/${classifier}/runtime.node`; -execFileSync('tar', ['-xzf', tarballPath, '-C', outDir, memberPath], { stdio: 'inherit' }); -fs.renameSync(path.join(outDir, memberPath), runtimePath); - -// Extract the copilot CLI executable (necessary-and-sufficient runtime artifact invariant: -// host_start needs both runtime.node and the copilot CLI from the same package version). -execFileSync('tar', ['-xzf', tarballPath, '-C', outDir, cliTarballMember], { stdio: 'inherit' }); -fs.renameSync(path.join(outDir, cliTarballMember), cliPath); -if (!isWindows) { - fs.chmodSync(cliPath, 0o755); +const inventory = []; +const members = execFileSync('tar', ['-tzf', tarballPath], { encoding: 'utf8' }) + .split(/\r?\n/) + .filter(Boolean); +for (const member of members) { + const destinationRelative = hostlessRuntimePath(member, classifier); + if (destinationRelative === null) { + continue; + } + const listing = execFileSync('tar', ['-tvzf', tarballPath, member], { encoding: 'utf8' }).trim(); + if (listing.startsWith('d')) { + continue; + } + if (!listing.startsWith('-')) { + throw new Error(`Unsupported runtime package entry: ${member}`); + } + const content = execFileSync('tar', ['-xOzf', tarballPath, member], { + encoding: null, + maxBuffer: 512 * 1024 * 1024, + }); + const destination = path.resolve(resourceDir, destinationRelative); + const resourceRoot = `${path.resolve(resourceDir)}${path.sep}`; + if (!destination.startsWith(resourceRoot)) { + throw new Error(`Runtime package entry escapes staging directory: ${member}`); + } + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, content); + const mode = listing.slice(0, 10).includes('x') ? 0o755 : 0o644; + fs.chmodSync(destination, mode); + inventory.push(`${mode.toString(8)}\t${destinationRelative.split(path.sep).join('/')}`); } +inventory.sort(); +fs.writeFileSync(inventoryPath, `${inventory.join('\n')}\n`); -fs.rmSync(path.join(outDir, 'package'), { recursive: true, force: true }); fs.rmSync(tarballPath, { force: true }); +if (!fs.existsSync(runtimePath) || !fs.existsSync(wrapperPath)) { + throw new Error(`Package ${packageName}@${version} is missing the runtime wrapper pair`); +} fs.writeFileSync(platformPropertiesPath, expectedPlatformProperties); -const runtimeDigest = digestFile(runtimePath); -const cliDigest = digestFile(cliPath); -fs.writeFileSync(stampPath, `${version}\n${integrity}\n${runtimeDigest}\n${cliDigest}\n`); +const treeDigest = digestTree(resourceDir); +fs.writeFileSync(stampPath, `${stagingSchema}\n${version}\n${integrity}\n${treeDigest}\n`); console.log(`Staged ${runtimePath}`); -function digestFile(filePath) { - return `sha512-${createHash('sha512').update(fs.readFileSync(filePath)).digest('base64')}`; +function hostlessRuntimePath(packageRelative, platform) { + if (packageRelative.includes('\\')) { + return null; + } + const parts = packageRelative.split('/'); + if (parts[0] !== 'package' || parts.some((part) => !part || part === '..')) { + return null; + } + parts.shift(); + const topLevel = parts[0]; + const fileName = parts.at(-1); + if ( + excludedTopLevel.has(topLevel) || + (topLevel.startsWith('tree-sitter') && topLevel.endsWith('.wasm')) || + (topLevel.startsWith('voice-') && topLevel.endsWith('.js')) || + fileName === 'cli-native.node' || + parts.includes('mediaremote-adapter') || + fileName.startsWith('copilot-runtime-bin') + ) { + return null; + } + if (topLevel === 'prebuilds') { + if (parts[1] !== platform || parts.length < 3) { + return null; + } + return path.join(...parts.slice(2)); + } + return path.join(...parts); +} + +function walkFiles(directory) { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files; +} + +function digestTree(directory) { + const hash = createHash('sha512'); + for (const file of walkFiles(directory).sort()) { + const relative = path.relative(directory, file).split(path.sep).join('/'); + hash.update(relative).update('\0').update(fs.readFileSync(file)).update('\0'); + } + return `sha512-${hash.digest('base64')}`; } diff --git a/java/copilot-native/scripts/fetch-native.test.mjs b/java/copilot-native/scripts/fetch-native.test.mjs index 3ca1e2fde6..582ffa397f 100644 --- a/java/copilot-native/scripts/fetch-native.test.mjs +++ b/java/copilot-native/scripts/fetch-native.test.mjs @@ -7,29 +7,41 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; const version = '1.0.79'; const integrity = 'sha512-test-integrity'; const runtimeContent = 'runtime content'; -const cliContent = 'cli content'; +const wrapperContent = 'wrapper content'; +const stagingSchema = 'hostless-runtime-v2'; const scriptPath = fileURLToPath(new URL('./fetch-native.mjs', import.meta.url)); for (const classifier of ['linux-x64', 'linux-arm64', 'win32-x64', 'win32-arm64', 'darwin-arm64']) { - test(`${classifier}: missing CLI does not use incremental fast path`, (t) => { + test(`${classifier}: complete hostless artifacts use incremental fast path without a CLI`, (t) => { const fixture = createFixture(t, classifier); - fs.rmSync(fixture.cliPath); + + const result = runScript(fixture); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /already staged/); + assert.equal(fs.existsSync(fixture.npmMarkerPath), false); + }); + + test(`${classifier}: missing runtime wrapper does not use incremental fast path`, (t) => { + const fixture = createFixture(t, classifier); + fs.rmSync(fixture.wrapperPath); const result = runScript(fixture); assertRestagingAttempted(fixture, result); }); - test(`${classifier}: stale CLI does not use incremental fast path`, (t) => { + test(`${classifier}: legacy staging schema does not use incremental fast path`, (t) => { const fixture = createFixture(t, classifier); - fs.writeFileSync(fixture.cliPath, 'stale CLI content'); + const stampPath = path.join(fixture.stagingDir, classifier, '.version'); + fs.writeFileSync(stampPath, fs.readFileSync(stampPath, 'utf8').replace(stagingSchema, 'hostless-runtime-v1')); const result = runScript(fixture); @@ -45,6 +57,15 @@ for (const classifier of ['linux-x64', 'linux-arm64', 'win32-x64', 'win32-arm64' assertRestagingAttempted(fixture, result); }); + test(`${classifier}: missing retained runtime asset does not use incremental fast path`, (t) => { + const fixture = createFixture(t, classifier); + fs.rmSync(fixture.ripgrepPath); + + const result = runScript(fixture); + + assertRestagingAttempted(fixture, result); + }); + test(`${classifier}: complete matching artifacts use incremental fast path`, (t) => { const fixture = createFixture(t, classifier); @@ -56,6 +77,55 @@ for (const classifier of ['linux-x64', 'linux-arm64', 'win32-x64', 'win32-arm64' }); } +test('stages retained package assets and excludes CLI-only content', (t) => { + const classifier = 'linux-x64'; + const fixture = createFixture(t, classifier); + const packageRoot = path.join(fixture.repoRoot, 'package-root', 'package'); + fs.mkdirSync(path.join(packageRoot, 'prebuilds', classifier), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, 'ripgrep', 'bin', classifier), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, 'definitions'), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, 'copilot'), 'excluded'); + fs.writeFileSync(path.join(packageRoot, 'prebuilds', classifier, 'runtime.node'), runtimeContent); + fs.writeFileSync(path.join(packageRoot, 'prebuilds', classifier, 'copilot-runtime'), wrapperContent); + fs.writeFileSync(path.join(packageRoot, 'ripgrep', 'bin', classifier, 'rg'), 'ripgrep content'); + fs.chmodSync(path.join(packageRoot, 'ripgrep', 'bin', classifier, 'rg'), 0o755); + fs.writeFileSync(path.join(packageRoot, 'definitions', 'future.json'), '{}'); + fs.writeFileSync(path.join(packageRoot, 'app.js'), 'excluded'); + fs.writeFileSync(path.join(packageRoot, 'LICENSE.md'), 'excluded'); + fs.writeFileSync(path.join(packageRoot, 'README.md'), 'excluded'); + const tarball = path.join(fixture.repoRoot, 'fixture.tgz'); + execFileSync('tar', ['-czf', tarball, '-C', path.dirname(packageRoot), 'package']); + const packageIntegrity = digest(fs.readFileSync(tarball)); + fs.writeFileSync( + path.join(fixture.repoRoot, 'nodejs', 'package-lock.json'), + JSON.stringify({ + packages: { + [`node_modules/@github/copilot-${classifier}`]: { version, integrity: packageIntegrity }, + }, + }), + ); + const fakeNpmPath = path.join(fixture.fakeBinDir, process.platform === 'win32' ? 'npm.cmd' : 'npm'); + const fakeNpm = + process.platform === 'win32' + ? '@copy "%FETCH_NATIVE_TARBALL%" "%4\\fixture.tgz" >nul\r\n@echo fixture.tgz\r\n' + : '#!/bin/sh\ncp "$FETCH_NATIVE_TARBALL" "$4/fixture.tgz"\nprintf "fixture.tgz\\n"\n'; + fs.writeFileSync(fakeNpmPath, fakeNpm); + fs.chmodSync(fakeNpmPath, 0o755); + fs.rmSync(path.join(fixture.stagingDir, classifier), { recursive: true, force: true }); + + const result = runScript(fixture, { FETCH_NATIVE_TARBALL: tarball }); + + assert.equal(result.status, 0, result.stderr); + const resourceDir = path.join(fixture.stagingDir, classifier, 'native', classifier); + assert.equal(fs.readFileSync(path.join(resourceDir, 'ripgrep', 'bin', classifier, 'rg'), 'utf8'), 'ripgrep content'); + assert.equal(fs.readFileSync(path.join(resourceDir, 'definitions', 'future.json'), 'utf8'), '{}'); + assert.equal(fs.existsSync(path.join(resourceDir, 'app.js')), false); + assert.equal(fs.existsSync(path.join(resourceDir, 'copilot')), false); + assert.equal(fs.existsSync(path.join(resourceDir, 'LICENSE.md')), false); + assert.equal(fs.existsSync(path.join(resourceDir, 'README.md')), false); + assert.match(fs.readFileSync(path.join(resourceDir, 'runtime-assets.list'), 'utf8'), /ripgrep\/bin\/linux-x64\/rg/); +}); + function createFixture(t, classifier) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-native-test-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); @@ -79,14 +149,25 @@ function createFixture(t, classifier) { ); const runtimePath = path.join(resourceDir, 'runtime.node'); - const cliPath = path.join(resourceDir, classifier.startsWith('win32') ? 'copilot.exe' : 'copilot'); + const wrapperPath = path.join( + resourceDir, + classifier.startsWith('win32') ? 'copilot-runtime.exe' : 'copilot-runtime', + ); const platformPropertiesPath = path.join(resourceDir, 'platform.properties'); + const ripgrepPath = path.join(resourceDir, 'ripgrep', 'bin', classifier, 'rg'); + const inventoryPath = path.join(resourceDir, 'runtime-assets.list'); + fs.mkdirSync(path.dirname(ripgrepPath), { recursive: true }); fs.writeFileSync(runtimePath, runtimeContent); - fs.writeFileSync(cliPath, cliContent); + fs.writeFileSync(wrapperPath, wrapperContent); + fs.writeFileSync(ripgrepPath, 'ripgrep content'); + fs.writeFileSync( + inventoryPath, + `644\truntime.node\n755\tcopilot-runtime\n755\tripgrep/bin/${classifier}/rg\n`, + ); fs.writeFileSync(platformPropertiesPath, `classifier=${classifier}\nversion=${version}\n`); fs.writeFileSync( path.join(stagingDir, classifier, '.version'), - `${version}\n${integrity}\n${digest(runtimeContent)}\n${digest(cliContent)}\n`, + `${stagingSchema}\n${version}\n${integrity}\n${digestTree(resourceDir)}\n`, ); const fakeNpmPath = path.join(fakeBinDir, process.platform === 'win32' ? 'npm.cmd' : 'npm'); @@ -104,18 +185,20 @@ function createFixture(t, classifier) { fakeBinDir, npmMarkerPath, runtimePath, - cliPath, + wrapperPath, + ripgrepPath, platformPropertiesPath, }; } -function runScript(fixture) { +function runScript(fixture, extraEnv = {}) { return spawnSync(process.execPath, [scriptPath, fixture.repoRoot, fixture.stagingDir, fixture.classifier], { encoding: 'utf8', env: { ...process.env, PATH: `${fixture.fakeBinDir}${path.delimiter}${process.env.PATH}`, FETCH_NATIVE_NPM_MARKER: fixture.npmMarkerPath, + ...extraEnv, }, }); } @@ -125,6 +208,29 @@ function assertRestagingAttempted(fixture, result) { assert.equal(fs.readFileSync(fixture.npmMarkerPath, 'utf8').trim(), 'invoked'); } +function digestTree(directory) { + const hash = createHash('sha512'); + for (const file of walkFiles(directory).sort()) { + const relative = path.relative(directory, file).split(path.sep).join('/'); + hash.update(relative).update('\0').update(fs.readFileSync(file)).update('\0'); + } + + return `sha512-${hash.digest('base64')}`; +} + function digest(content) { return `sha512-${createHash('sha512').update(content).digest('base64')}`; } + +function walkFiles(directory) { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(entryPath)); + } else { + files.push(entryPath); + } + } + return files; +} diff --git a/java/copilot-native/scripts/validate-native-artifact.mjs b/java/copilot-native/scripts/validate-native-artifact.mjs index dda86a917a..9af4ffd772 100644 --- a/java/copilot-native/scripts/validate-native-artifact.mjs +++ b/java/copilot-native/scripts/validate-native-artifact.mjs @@ -27,13 +27,13 @@ export function validateNativeClassifierJar({ } const archive = readJar(jarPath); - const cliFilename = classifier.startsWith("win32") - ? "copilot.exe" - : "copilot"; + const runtimeFilename = classifier.startsWith("win32") + ? "copilot-runtime.exe" + : "copilot-runtime"; const resourcePrefix = `native/${classifier}/`; const requiredEntries = [ `${resourcePrefix}runtime.node`, - `${resourcePrefix}${cliFilename}`, + `${resourcePrefix}${runtimeFilename}`, `${resourcePrefix}platform.properties`, ]; diff --git a/java/copilot-native/scripts/validate-native-artifact.test.mjs b/java/copilot-native/scripts/validate-native-artifact.test.mjs index eb99d432db..25851bb575 100644 --- a/java/copilot-native/scripts/validate-native-artifact.test.mjs +++ b/java/copilot-native/scripts/validate-native-artifact.test.mjs @@ -181,7 +181,7 @@ test("rejects missing native resources", (t) => { expectedFilename: artifactName, repoRoot: fixture.repoRoot, }), - /copilot\.exe/, + /copilot-runtime\.exe/, ); }); @@ -189,7 +189,7 @@ test("rejects incorrect pinned package metadata", (t) => { const fixture = createFixture(t); writeStoredZip(fixture.jarPath, [ ["native/win32-x64/runtime.node", "runtime"], - ["native/win32-x64/copilot.exe", "cli"], + ["native/win32-x64/copilot-runtime.exe", "runtime wrapper"], [ "native/win32-x64/platform.properties", "classifier=win32-x64\nversion=0.0.1\n", @@ -217,7 +217,7 @@ test("rejects Linux resources in a Windows classifier", (t) => { }); writeStoredZip(fixture.jarPath, [ ["native/win32-x64/runtime.node", "runtime"], - ["native/win32-x64/copilot.exe", "cli"], + ["native/win32-x64/copilot-runtime.exe", "runtime wrapper"], [ "native/win32-x64/platform.properties", "classifier=win32-x64\nversion=9.8.7\n", @@ -245,7 +245,7 @@ test("rejects Windows resources in a Linux classifier", (t) => { const linuxJarPath = path.join(fixture.root, linuxArtifactName); writeStoredZip(linuxJarPath, [ ["native/linux-x64/runtime.node", "runtime"], - ["native/linux-x64/copilot", "cli"], + ["native/linux-x64/copilot-runtime", "runtime wrapper"], [ "native/linux-x64/platform.properties", "classifier=linux-x64\nversion=9.8.6\n", @@ -459,7 +459,7 @@ test("local publication validation rejects cross-classifier contamination", (t) path.join(publicationDirectory, `${artifactId}-${version}-linux-x64.jar`), [ ["native/linux-x64/runtime.node", "runtime"], - ["native/linux-x64/copilot", "cli"], + ["native/linux-x64/copilot-runtime", "runtime wrapper"], [ "native/linux-x64/platform.properties", "classifier=linux-x64\nversion=9.8.6\n", diff --git a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java index acc683a720..ff0d121945 100644 --- a/java/sdk/src/main/java/com/github/copilot/CliServerManager.java +++ b/java/sdk/src/main/java/com/github/copilot/CliServerManager.java @@ -11,6 +11,7 @@ import java.net.Socket; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -19,6 +20,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import com.github.copilot.ffi.NativeRuntimeLoader; import com.github.copilot.rpc.CopilotClientOptions; /** @@ -64,7 +66,7 @@ void setConnectionToken(String connectionToken) { ProcessInfo startCliServer() throws IOException, InterruptedException { clearStderrBuffer(); - String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot"; + RuntimeLaunch launch = resolveCliLaunch(); var args = new ArrayList(); if (options.getCliArgs() != null) { @@ -106,7 +108,7 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { args.add("--remote"); } - List command = resolveCliCommand(cliPath, args); + List command = resolveCliCommand(launch.executable(), args); var pb = new ProcessBuilder(command); pb.redirectErrorStream(false); @@ -122,51 +124,7 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { pb.directory(new File(options.getCwd())); } - if (options.getEnvironment() != null) { - pb.environment().clear(); - pb.environment().putAll(options.getEnvironment()); - } - pb.environment().remove("NODE_DEBUG"); - - // Set auth token in environment if provided - if (options.getGitHubToken() != null && !options.getGitHubToken().isEmpty()) { - pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken()); - } - - // Set Copilot home directory if configured - if (options.getCopilotHome() != null && !options.getCopilotHome().isEmpty()) { - pb.environment().put("COPILOT_HOME", options.getCopilotHome()); - } - - // Set connection token for TCP mode - if (connectionToken != null && !connectionToken.isEmpty()) { - pb.environment().put("COPILOT_CONNECTION_TOKEN", connectionToken); - } - - // Set telemetry environment variables if configured - if (options.getTelemetry() != null) { - var telemetry = options.getTelemetry(); - pb.environment().put("COPILOT_OTEL_ENABLED", "true"); - if (telemetry.getOtlpEndpoint() != null) { - pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint()); - } - if (telemetry.getOtlpProtocol() != null) { - pb.environment().put("OTEL_EXPORTER_OTLP_PROTOCOL", telemetry.getOtlpProtocol()); - } - if (telemetry.getFilePath() != null) { - pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath()); - } - if (telemetry.getExporterType() != null) { - pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType()); - } - if (telemetry.getSourceName() != null) { - pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName()); - } - if (telemetry.getCaptureContent().isPresent()) { - pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", - telemetry.getCaptureContent().get() ? "true" : "false"); - } - } + configureProcessEnvironment(pb); Process process = pb.start(); @@ -310,6 +268,75 @@ private List resolveCliCommand(String cliPath, List args) { return result; } + void configureProcessEnvironment(ProcessBuilder pb) { + if (options.getEnvironment() != null) { + pb.environment().clear(); + pb.environment().putAll(options.getEnvironment()); + } + pb.environment().remove("NODE_DEBUG"); + + // Set auth token in environment if provided + if (options.getGitHubToken() != null && !options.getGitHubToken().isEmpty()) { + pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken()); + } + + // Set Copilot home directory if configured + if (options.getCopilotHome() != null && !options.getCopilotHome().isEmpty()) { + pb.environment().put("COPILOT_HOME", options.getCopilotHome()); + } + + // Set connection token for TCP mode + if (connectionToken != null && !connectionToken.isEmpty()) { + pb.environment().put("COPILOT_CONNECTION_TOKEN", connectionToken); + } + + // Set telemetry environment variables if configured + if (options.getTelemetry() != null) { + var telemetry = options.getTelemetry(); + pb.environment().put("COPILOT_OTEL_ENABLED", "true"); + if (telemetry.getOtlpEndpoint() != null) { + pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint()); + } + if (telemetry.getOtlpProtocol() != null) { + pb.environment().put("OTEL_EXPORTER_OTLP_PROTOCOL", telemetry.getOtlpProtocol()); + } + if (telemetry.getFilePath() != null) { + pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath()); + } + if (telemetry.getExporterType() != null) { + pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType()); + } + if (telemetry.getSourceName() != null) { + pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName()); + } + if (telemetry.getCaptureContent().isPresent()) { + pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", + telemetry.getCaptureContent().get() ? "true" : "false"); + } + } + } + + RuntimeLaunch resolveCliLaunch() throws IOException { + return resolveCliLaunch(System.getenv(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV)); + } + + RuntimeLaunch resolveCliLaunch(String inheritedCliPath) throws IOException { + if (options.getCliPath() != null) { + return new RuntimeLaunch(options.getCliPath()); + } + + var environment = options.getEnvironment(); + String envCliPath = environment != null + ? environment.get(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV) + : inheritedCliPath; + if (envCliPath != null && !envCliPath.isBlank()) { + return new RuntimeLaunch(envCliPath); + } + + Path wrapper = NativeRuntimeLoader.resolveRuntimeWrapper(); + return new RuntimeLaunch(wrapper.toString()); + } + static URI parseCliUrl(String url) { // If it's just a port number, treat as localhost try { @@ -337,4 +364,7 @@ static URI parseCliUrl(String url) { */ record ProcessInfo(Process process, Integer port) { } + + record RuntimeLaunch(String executable) { + } } diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java index ea2b0b67dd..661fe6dab4 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -8,6 +8,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.URI; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -479,7 +480,8 @@ void setInProcessTransportFactory(InProcessTransportFactory factory) { private static InProcessTransport openInProcessTransport(CopilotClientOptions options) throws IOException { FfiRuntimeHost host = new FfiRuntimeHost(); try { - host.start(resolveInProcessEntrypoint(), options); + Path explicitEntrypoint = NativeRuntimeLoader.resolveConfiguredEntrypoint(); + host.start(explicitEntrypoint == null ? null : explicitEntrypoint.toString(), options); } catch (RuntimeException | Error e) { host.close(); throw e; @@ -487,15 +489,6 @@ private static InProcessTransport openInProcessTransport(CopilotClientOptions op return new InProcessTransport(host.getReceiveStream(), host.getSendStream(), host); } - /** - * Resolves the runtime entrypoint handed to the in-process host. The copilot - * CLI executable is resolved from the same bundled location as - * {@code runtime.node} — no environment variables or PATH search. - */ - private static String resolveInProcessEntrypoint() throws IOException { - return NativeRuntimeLoader.resolveEntrypoint().toString(); - } - private static void closeRuntimeHost(AutoCloseable host) { try { host.close(); diff --git a/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java b/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java index 5e7d2d461a..cb5bba1af1 100644 --- a/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java +++ b/java/sdk/src/main/java/com/github/copilot/ffi/FfiRuntimeHost.java @@ -88,13 +88,13 @@ private static Path resolveLibraryPath() throws IOException { * Starts the in-process runtime and opens a connection. * * @param entrypointPath - * runtime entrypoint path passed in {@code argv_json} + * optional explicit legacy CLI entrypoint passed in + * {@code argv_json} * @param options * client options used to construct {@code argv_json} and * {@code env_json} */ public void start(String entrypointPath, CopilotClientOptions options) { - Objects.requireNonNull(entrypointPath, "entrypointPath must not be null"); Objects.requireNonNull(options, "options must not be null"); if (disposed.get()) { throw new IllegalStateException("FfiRuntimeHost is already closed."); @@ -108,8 +108,7 @@ public void start(String entrypointPath, CopilotClientOptions options) { int hostHandle = runHostStartOnBlockingThread(argvJson, envJson); if (hostHandle == 0) { String lib = libraryPath != null ? libraryPath : ""; - throw new IllegalStateException( - "copilot_runtime_host_start failed (library '" + lib + "', entrypoint '" + entrypointPath + "')."); + throw new IllegalStateException("copilot_runtime_host_start failed (library '" + lib + "')."); } // Hold operationLock while publishing handles to serialize with close(). @@ -270,12 +269,14 @@ private int runHostStartOnBlockingThread(byte[] argvJson, byte[] envJson) { private static byte[] buildArgvJson(String entrypointPath, CopilotClientOptions options) { List argv = new ArrayList<>(); - if (entrypointPath.toLowerCase().endsWith(".js")) { - argv.add("node"); + if (entrypointPath != null) { + if (entrypointPath.toLowerCase().endsWith(".js")) { + argv.add("node"); + } + argv.add(entrypointPath); + argv.add("--embedded-host"); + argv.add("--no-auto-update"); } - argv.add(entrypointPath); - argv.add("--embedded-host"); - argv.add("--no-auto-update"); String logLevel = options.getLogLevel(); if (logLevel != null && !logLevel.isBlank()) { diff --git a/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java b/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java index 6733f4afb2..bd4b185a07 100644 --- a/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java +++ b/java/sdk/src/main/java/com/github/copilot/ffi/NativeRuntimeLoader.java @@ -5,9 +5,12 @@ package com.github.copilot.ffi; import java.io.FileNotFoundException; +import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.channels.FileChannel; import java.nio.file.AccessDeniedException; import java.nio.file.AtomicMoveNotSupportedException; @@ -42,7 +45,10 @@ public final class NativeRuntimeLoader { static final String RUNTIME_FILENAME = "runtime.node"; static final String CLI_FILENAME = "copilot"; static final String CLI_FILENAME_WINDOWS = "copilot.exe"; + static final String RUNTIME_WRAPPER_FILENAME = "copilot-runtime"; + static final String RUNTIME_WRAPPER_FILENAME_WINDOWS = "copilot-runtime.exe"; static final String PLATFORM_PROPERTIES_FILENAME = "platform.properties"; + static final String RUNTIME_ASSETS_FILENAME = "runtime-assets.list"; /** Environment variable that overrides where the runtime is loaded from. */ public static final String COPILOT_CLI_PATH_ENV = "COPILOT_CLI_PATH"; static final String VERSION_RESOURCE = "copilot-runtime.properties"; @@ -125,10 +131,10 @@ public static Path resolve() throws IOException { } /** - * Resolves the copilot CLI executable from the same location as the bundled - * {@code runtime.node}. The CLI is used as {@code argv[0]} in - * {@code copilot_runtime_host_start} — the Rust runtime spawns it as a child - * process. + * Resolves the legacy copilot CLI entrypoint from the same location as the + * bundled {@code runtime.node}. Callers may pass this entrypoint through + * {@code copilot_runtime_host_start} when legacy extension hosting is + * requested. * *

* This method calls {@link #resolve()} to locate {@code runtime.node}, then @@ -144,6 +150,73 @@ public static Path resolveEntrypoint() throws IOException { return resolveEntrypoint(configuredCli, resolve()); } + /** + * Resolves an explicitly configured legacy CLI entrypoint, if it has a + * compatible adjacent runtime library. + * + * @return the absolute CLI path, or {@code null} when no compatible override is + * configured + * @throws IOException + * if the configured files cannot be inspected + */ + public static Path resolveConfiguredEntrypoint() throws IOException { + String configuredCli = System.getenv(COPILOT_CLI_PATH_ENV); + if (configuredCli == null || configuredCli.isBlank()) { + return null; + } + Path configuredPath = Path.of(configuredCli).toAbsolutePath().normalize(); + return resolveFromCliPath(configuredCli) != null && Files.isRegularFile(configuredPath) + && Files.size(configuredPath) > 0 ? configuredPath : null; + } + + /** + * Resolves the out-of-process runtime wrapper from the platform classifier JAR + * and extracts it beside {@code runtime.node}. + * + * @return absolute path to the runtime wrapper executable + * @throws IOException + * if the classifier artifacts cannot be extracted + */ + public static Path resolveRuntimeWrapper() throws IOException { + ClassLoader loader = NativeRuntimeLoader.class.getClassLoader(); + String classifier = PlatformDetector.detectClassifier(); + String version = readVersion(loader); + return resolveRuntimeWrapper(defaultCacheBase(), loader, classifier, version); + } + + static Path resolveRuntimeWrapper(Path cacheBase, ClassLoader loader, String classifier, String version) + throws IOException { + Path runtimePath = extractRuntimeToCache(cacheBase, loader, classifier, version, DEFAULT_PUBLISHER, false); + Path cacheDir = runtimePath.getParent(); + String wrapperName = classifier.startsWith("win32-") + ? RUNTIME_WRAPPER_FILENAME_WINDOWS + : RUNTIME_WRAPPER_FILENAME; + Path cachedWrapper = cacheDir.resolve(wrapperName); + if (isValidCachedCli(cachedWrapper)) { + return cachedWrapper; + } + + String resourcePath = "native/" + classifier + "/" + wrapperName; + URL resource = loader.getResource(resourcePath); + if (resource == null) { + throw new FileNotFoundException("Runtime wrapper not found on classpath: " + resourcePath + + " — add the matching classifier JAR to the classpath"); + } + + Path temp = Files.createTempFile(cacheDir, "runtime-wrapper-tmp-", ""); + try { + copyResourceToTemp(resource, resourcePath, temp); + makeExecutable(temp); + DEFAULT_PUBLISHER.publish(temp, cachedWrapper); + } finally { + tryDelete(temp); + } + if (!isValidCachedCli(cachedWrapper)) { + throw new IOException("Published runtime wrapper is not a non-empty executable file: " + cachedWrapper); + } + return cachedWrapper; + } + static Path resolveEntrypoint(String configuredCli, Path runtimePath) throws IOException { if (configuredCli != null && !configuredCli.isBlank()) { Path configuredPath = Path.of(configuredCli).toAbsolutePath().normalize(); @@ -318,6 +391,11 @@ static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier */ static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier, String version, AtomicPublisher publisher) throws IOException { + return extractRuntimeToCache(cacheBase, loader, classifier, version, publisher, true); + } + + private static Path extractRuntimeToCache(Path cacheBase, ClassLoader loader, String classifier, String version, + AtomicPublisher publisher, boolean extractCli) throws IOException { String resourcePath = "native/" + classifier + "/" + RUNTIME_FILENAME; String nativeVersion = readNativePackageVersion(loader, classifier); Path cacheDir = cacheBase.resolve(version).resolve(nativeVersion).resolve(classifier); @@ -325,7 +403,10 @@ static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier // Step 1 — fast path: return an existing valid cache entry. if (isValidCachedFile(cached)) { - extractCliToCache(cacheDir, loader, classifier, publisher); + extractRuntimeAssetsToCache(cacheDir, loader, classifier, publisher); + if (extractCli) { + extractCliToCache(cacheDir, loader, classifier, publisher); + } return cached; } @@ -348,12 +429,68 @@ static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier tryDelete(temp); } - // Step 5 — also extract the copilot CLI executable alongside runtime.node. - extractCliToCache(cacheDir, loader, classifier, publisher); + extractRuntimeAssetsToCache(cacheDir, loader, classifier, publisher); + if (extractCli) { + extractCliToCache(cacheDir, loader, classifier, publisher); + } return cached; } + private static void extractRuntimeAssetsToCache(Path cacheDir, ClassLoader loader, String classifier, + AtomicPublisher publisher) throws IOException { + String inventoryResourcePath = "native/" + classifier + "/" + RUNTIME_ASSETS_FILENAME; + URL inventoryResource = loader.getResource(inventoryResourcePath); + if (inventoryResource == null) { + return; + } + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(inventoryResource.openStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + String[] fields = line.split("\\t", 2); + if (fields.length != 2) { + throw new IOException("Invalid runtime asset inventory entry: " + line); + } + boolean executable = (Integer.parseInt(fields[0], 8) & 0111) != 0; + Path relative = Path.of(fields[1]).normalize(); + if (relative.isAbsolute() || relative.startsWith("..")) { + throw new IOException("Unsafe runtime asset inventory path: " + fields[1]); + } + Path cached = cacheDir.resolve(relative).normalize(); + if (!cached.startsWith(cacheDir)) { + throw new IOException("Runtime asset escapes cache directory: " + fields[1]); + } + if (isValidCachedFile(cached) && (!executable || isWindows() || Files.isExecutable(cached))) { + continue; + } + + String resourcePath = "native/" + classifier + "/" + fields[1]; + URL resource = loader.getResource(resourcePath); + if (resource == null) { + throw new FileNotFoundException("Runtime asset not found on classpath: " + resourcePath); + } + Files.createDirectories(cached.getParent()); + Path temp = Files.createTempFile(cached.getParent(), "runtime-asset-tmp-", ""); + try { + copyResourceToTemp(resource, resourcePath, temp); + if (executable) { + makeExecutable(temp); + } + publisher.publish(temp, cached); + } finally { + tryDelete(temp); + } + } + } catch (NumberFormatException ex) { + throw new IOException("Invalid runtime asset mode in " + inventoryResourcePath, ex); + } + } + /** * Extracts the copilot CLI executable from the classpath to the same cache * directory as {@code runtime.node}. Idempotent — skips extraction if already diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index d3515509b5..a9c9cbcda7 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -192,19 +192,20 @@ public CopilotClientOptions setCliArgs(String[] cliArgs) { } /** - * Gets the path to the Copilot CLI executable. + * Gets the path to an explicitly configured Copilot executable. * - * @return the CLI path, or {@code null} to use "copilot" from PATH + * @return the executable path, or {@code null} to use the bundled runtime + * wrapper */ public String getCliPath() { return cliPath; } /** - * Sets the path to the Copilot CLI executable. + * Sets the path to the Copilot CLI or runtime wrapper executable. * * @param cliPath - * the path to the CLI executable + * the path to the executable * @return this options instance for method chaining */ public CopilotClientOptions setCliPath(String cliPath) { diff --git a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java index 68555a35b4..a227be04b9 100644 --- a/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CliServerManagerTest.java @@ -9,9 +9,13 @@ import java.io.IOException; import java.net.ServerSocket; import java.net.URI; +import java.nio.file.Path; +import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.github.copilot.ffi.NativeRuntimeLoader; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.TelemetryConfig; @@ -22,6 +26,47 @@ */ class CliServerManagerTest { + @TempDir + Path tempDir; + + @Test + void explicitCliPathDoesNotRequireRuntimeBundle() throws Exception { + Path explicit = tempDir.resolve("copilot"); + var manager = new CliServerManager(new CopilotClientOptions().setCliPath(explicit.toString())); + + assertEquals(explicit.toString(), manager.resolveCliLaunch().executable()); + } + + @Test + void inheritedCliPathEnvironmentOverrideDoesNotRequireRuntimeBundle() throws Exception { + Path inherited = tempDir.resolve("copilot-runtime"); + var manager = new CliServerManager(new CopilotClientOptions()); + + assertEquals(inherited.toString(), manager.resolveCliLaunch(inherited.toString()).executable()); + } + + @Test + void configuredEnvironmentCliPathOverridesInheritedEnvironment() throws Exception { + Path inherited = tempDir.resolve("inherited-copilot-runtime"); + Path configured = tempDir.resolve("configured-copilot-runtime"); + var options = new CopilotClientOptions() + .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString())); + var manager = new CliServerManager(options); + + assertEquals(configured.toString(), manager.resolveCliLaunch(inherited.toString()).executable()); + } + + @Test + void explicitCliPathOverridesEnvironment() throws Exception { + Path explicit = tempDir.resolve("explicit-copilot-runtime"); + Path configured = tempDir.resolve("configured-copilot-runtime"); + var options = new CopilotClientOptions().setCliPath(explicit.toString()) + .setEnvironment(Map.of(NativeRuntimeLoader.COPILOT_CLI_PATH_ENV, configured.toString())); + var manager = new CliServerManager(options); + + assertEquals(explicit.toString(), manager.resolveCliLaunch("inherited-copilot-runtime").executable()); + } + // ===== parseCliUrl tests ===== @Test diff --git a/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java b/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java index 23408faa2a..0294b511ec 100644 --- a/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java +++ b/java/sdk/src/test/java/com/github/copilot/e2e/InProcessTransportIT.java @@ -50,10 +50,10 @@ * *

* Run with {@code mvn verify -Pinprocess} from the {@code java} reactor root, - * which builds the {@code copilot-sdk-java-runtime} artifact and sets - * {@code COPILOT_CLI_PATH} to the pinned CLI whose sibling {@code runtime.node} - * this test loads, and forces {@code forkCount=1} because the FFI host and env - * guard mutate process-global state. + * which builds the {@code copilot-sdk-java-runtime} artifact and sets the + * classifier JAR containing {@code runtime.node}, and forces + * {@code forkCount=1} because the FFI host and env guard mutate process-global + * state. * *

* {@link RequireInProcess} disables this test unless the {@code -Pinprocess} @@ -86,11 +86,6 @@ void shouldStartPingAndStopOverInProcessFfi() throws Exception { // replay proxy, mirroring how a session-level in-process test would // redirect COPILOT_API_URL. `ping` never reaches the network, but this // demonstrates the guard's intended usage for future in-process tests. - // COPILOT_CLI_PATH is intentionally NOT set here: NativeRuntimeLoader and - // CopilotClient.resolveInProcessEntrypoint() read it via - // System.getenv(), which is a JVM-startup-time snapshot that native - // setenv() calls made after the JVM starts cannot update — it must be - // set before the JVM starts (see the -Pinprocess Maven profile). try (InProcessEnvGuard envGuard = new InProcessEnvGuard(Map.of("COPILOT_API_URL", ctx.getProxyUrl()))) { CopilotClientOptions options = new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()); try (CopilotClient client = new CopilotClient(options)) { diff --git a/java/sdk/src/test/java/com/github/copilot/e2e/OutOfProcessTransportIT.java b/java/sdk/src/test/java/com/github/copilot/e2e/OutOfProcessTransportIT.java new file mode 100644 index 0000000000..19c14a0644 --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/e2e/OutOfProcessTransportIT.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.e2e; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.AllowCopilotExperimental; +import com.github.copilot.CopilotClient; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.PingResponse; +import com.github.copilot.rpc.RuntimeConnection; + +/** + * Failsafe smoke test for the managed out-of-process runtime wrapper. + */ +@AllowCopilotExperimental +@RequireInProcess +class OutOfProcessTransportIT { + + @Test + void shouldStartPingAndStopOverStdio() throws Exception { + CopilotClientOptions options = new CopilotClientOptions().setConnection(RuntimeConnection.forStdio()); + try (CopilotClient client = new CopilotClient(options)) { + client.start().get(); + + PingResponse pong = client.ping("wrapper message").get(); + assertEquals("pong: wrapper message", pong.message()); + assertNotNull(pong.timestamp()); + + client.stop().get(); + } + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/e2e/RewindIT.java b/java/sdk/src/test/java/com/github/copilot/e2e/RewindIT.java index 9321618575..64eff21726 100644 --- a/java/sdk/src/test/java/com/github/copilot/e2e/RewindIT.java +++ b/java/sdk/src/test/java/com/github/copilot/e2e/RewindIT.java @@ -5,11 +5,9 @@ package com.github.copilot.e2e; 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.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeFalse; import java.nio.file.Files; import java.nio.file.Path; @@ -37,6 +35,8 @@ class RewindIT { private static final String FILE_NAME = "rewind-sdk.txt"; + private static final String ORIGINAL_FILE_CONTENT = "Original rewind content"; + private static final String PREPARED_FILE_CONTENT = "Prepared rewind content"; private static final String FILE_CONTENT = "SDK rewind content"; private static E2ETestContext ctx; @@ -55,11 +55,9 @@ static void teardown() throws Exception { @Test void shouldRestoreTrackedFileAndConversation() throws Exception { - assumeFalse(System.getProperty("os.name").startsWith("Windows"), - "blocked on CLI 1.0.81 file-change tracking regression on Windows"); - ctx.configureForTest("rewind", "should_restore_tracked_file_and_conversation"); Path filePath = ctx.getWorkDir().resolve(FILE_NAME); + Files.writeString(filePath, ORIGINAL_FILE_CONTENT); try (CopilotClient client = ctx.createClient(); CopilotSession session = client @@ -67,11 +65,19 @@ void shouldRestoreTrackedFileAndConversation() throws Exception { new SessionConfig().setModel("claude-sonnet-4.5").setEnableFileChangeTracking(true) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(30, TimeUnit.SECONDS)) { + AssistantMessageEvent ready = session + .sendAndWait(new MessageOptions().setPrompt("Use the edit tool to replace the exact contents of " + + FILE_NAME + " from " + ORIGINAL_FILE_CONTENT + " to " + PREPARED_FILE_CONTENT + + ". After the tool succeeds, reply with exactly SDK_REWIND_READY."), 30_000) + .get(60, TimeUnit.SECONDS); + assertNotNull(ready); + assertEquals("SDK_REWIND_READY", ready.getData().content()); + assertEquals(PREPARED_FILE_CONTENT, Files.readString(filePath)); + AssistantMessageEvent response = session - .sendAndWait(new MessageOptions().setPrompt( - "Use the create tool to create " + FILE_NAME + " containing exactly " + FILE_CONTENT - + ". After the tool succeeds, reply with exactly SDK_REWIND_DONE."), - 30_000) + .sendAndWait(new MessageOptions().setPrompt("Use the edit tool to replace the exact contents of " + + FILE_NAME + " from " + PREPARED_FILE_CONTENT + " to " + FILE_CONTENT + + ". After the tool succeeds, reply with exactly SDK_REWIND_DONE."), 30_000) .get(60, TimeUnit.SECONDS); assertNotNull(response); @@ -80,8 +86,9 @@ void shouldRestoreTrackedFileAndConversation() throws Exception { SessionHistoryListRewindPointsResult rewindPoints = waitForRewindPoints(session); assertTrue(Boolean.TRUE.equals(rewindPoints.fileChangeTrackingEnabled())); - assertEquals(1, rewindPoints.points().size()); - var rewindPoint = rewindPoints.points().get(0); + assertEquals(2, rewindPoints.points().size()); + var rewindPoint = rewindPoints.points().get(1); + assertTrue(Boolean.TRUE.equals(rewindPoint.turnChangedFiles())); assertTrue(Boolean.TRUE.equals(rewindPoint.canRestoreFiles())); assertEquals(1L, rewindPoint.fileCount()); @@ -98,7 +105,7 @@ void shouldRestoreTrackedFileAndConversation() throws Exception { assertTrue(rewind.eventsRemoved() != null && rewind.eventsRemoved() > 0); assertEquals(1, rewind.restoredFiles().size()); assertSamePath(filePath, rewind.restoredFiles().get(0)); - assertFalse(Files.exists(filePath)); + assertEquals(PREPARED_FILE_CONTENT, Files.readString(filePath)); var events = session.getMessages().get(10, TimeUnit.SECONDS); assertTrue(events.stream().noneMatch(event -> event.getId().toString().equals(rewindPoint.eventId()))); @@ -110,16 +117,19 @@ private static SessionHistoryListRewindPointsResult waitForRewindPoints(CopilotS SessionHistoryListRewindPointsResult result; do { result = session.getRpc().history.listRewindPoints().get(10, TimeUnit.SECONDS); - if (result.unavailableReason() == null && !result.points().isEmpty() - && Boolean.TRUE.equals(result.points().get(0).canRestoreFiles())) { + if (result.unavailableReason() == null && result.points().size() == 2 + && Boolean.TRUE.equals(result.points().get(1).turnChangedFiles()) + && Boolean.TRUE.equals(result.points().get(1).canRestoreFiles())) { return result; } TimeUnit.MILLISECONDS.sleep(100); } while (System.nanoTime() < deadline); assertNull(result.unavailableReason(), "Timed out waiting for rewind points to become available"); - assertFalse(result.points().isEmpty(), "Timed out waiting for a rewind point"); - assertTrue(Boolean.TRUE.equals(result.points().get(0).canRestoreFiles()), + assertTrue(result.points().size() >= 2, "Timed out waiting for both rewind points"); + assertTrue(Boolean.TRUE.equals(result.points().get(1).turnChangedFiles()), + "Timed out waiting for the edit turn to capture file changes"); + assertTrue(Boolean.TRUE.equals(result.points().get(1).canRestoreFiles()), "Timed out waiting for rewind file restoration to become available"); return result; } diff --git a/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java b/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java index cc98d24f6b..545b316c8c 100644 --- a/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ffi/FfiRuntimeHostTest.java @@ -99,6 +99,47 @@ public boolean connectionClose(int connectionId) { assertEquals("1", env.get("COPILOT_DISABLE_KEYTAR")); } + @Test + void startWithoutEntrypointPassesOnlyRuntimeOptions() throws Exception { + AtomicReference argvJson = new AtomicReference<>(); + NativeBinding binding = new NativeBinding() { + @Override + public int hostStart(byte[] argv, int argvLen, byte[] env, int envLen) { + argvJson.set(argv); + return 11; + } + + @Override + public boolean hostShutdown(int serverId) { + return true; + } + + @Override + public int connectionOpen(int serverId, OutboundCallback callback, Pointer userData, byte[] extSource, + int extSourceLen, byte[] extName, int extNameLen, byte[] connToken, int connTokenLen) { + return 21; + } + + @Override + public boolean connectionWrite(int connectionId, byte[] data, int dataLen) { + return true; + } + + @Override + public boolean connectionClose(int connectionId) { + return true; + } + }; + + try (FfiRuntimeHost host = new FfiRuntimeHost(binding, "/tmp/runtime.node")) { + host.start(null, new CopilotClientOptions().setLogLevel("debug")); + } + + List argv = MAPPER.readValue(argvJson.get(), new TypeReference>() { + }); + assertEquals(List.of("--log-level", "debug"), argv); + } + @Test void callbackExceptionIsContainedAndDoesNotEscapeAcrossFfiBoundary() { AtomicBoolean callbackReturned = new AtomicBoolean(false); diff --git a/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java b/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java index fc8bd51011..84ef7d8de4 100644 --- a/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ffi/NativeRuntimeLoaderTest.java @@ -34,17 +34,13 @@ class NativeRuntimeLoaderTest { - private static final String TEST_CLASSIFIER = PlatformDetector.detectClassifier(); - private static final String OTHER_CLASSIFIER = TEST_CLASSIFIER.equals("darwin-arm64") - ? "linux-x64" - : "darwin-arm64"; - private static final String TEST_CLI_FILENAME = TEST_CLASSIFIER.startsWith("win32") - ? NativeRuntimeLoader.CLI_FILENAME_WINDOWS - : NativeRuntimeLoader.CLI_FILENAME; + private static final String TEST_CLASSIFIER = "linux-x64"; + private static final String OTHER_CLASSIFIER = "darwin-arm64"; private static final String TEST_VERSION = "1.2.3-test"; private static final String TEST_NATIVE_VERSION = "0.0.1-test"; private static final byte[] FAKE_BINARY_CONTENT = "fake runtime.node binary content".getBytes(); private static final byte[] FAKE_CLI_CONTENT = "fake copilot CLI content".getBytes(); + private static final byte[] FAKE_WRAPPER_CONTENT = "fake runtime wrapper content".getBytes(); private static final byte[] OTHER_BINARY_CONTENT = "other runtime.node binary content".getBytes(); private static final byte[] OTHER_CLI_CONTENT = "other copilot CLI content".getBytes(); @@ -160,19 +156,24 @@ void resolveEntrypointUsesConfiguredCliWhenRuntimeIsInPrebuilds(@TempDir Path te @Test void resolveFromCliPathReturnsAbsolutePathForRelativeCliPath() throws Exception { Path workingDirectory = Path.of("").toAbsolutePath(); - Path fakeCliDir = Files.createTempDirectory(Path.of("target").toAbsolutePath(), "relative-cli-test-"); - Path fakeCliPath = fakeCliDir.resolve("copilot"); - Files.createFile(fakeCliPath); - Path runtimeNode = fakeCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME); - Files.write(runtimeNode, FAKE_BINARY_CONTENT); - - Path relativeCliPath = workingDirectory.relativize(fakeCliPath); - - assertEquals(runtimeNode, NativeRuntimeLoader.resolveFromCliPath(relativeCliPath.toString())); + Path fakeCliDir = Files.createTempDirectory(workingDirectory.resolve("target"), "relative-cli-"); + try { + Path fakeCliPath = Files.createFile(fakeCliDir.resolve("copilot")); + Path runtimeNode = Files.write(fakeCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME), + FAKE_BINARY_CONTENT); + Path relativeCliPath = workingDirectory.relativize(fakeCliPath); + + assertEquals(runtimeNode, NativeRuntimeLoader.resolveFromCliPath(relativeCliPath.toString())); + } finally { + Files.deleteIfExists(fakeCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME)); + Files.deleteIfExists(fakeCliDir.resolve("copilot")); + Files.deleteIfExists(fakeCliDir); + } } @Test void cliPathOverrideTakesPriorityOverClasspathExtraction(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); // Create a valid runtime.node alongside the fake CLI path Path fakeCliDir = tempDir.resolve("cli-dir"); Files.createDirectories(fakeCliDir); @@ -197,6 +198,7 @@ void cliPathOverrideTakesPriorityOverClasspathExtraction(@TempDir Path tempDir) @Test void extractToCacheCopiesResourceToVersionedCacheDirectory(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER); @@ -211,6 +213,7 @@ void extractToCacheCopiesResourceToVersionedCacheDirectory(@TempDir Path tempDir @Test void extractToCacheReturnsCachedFileOnSecondCall(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER); @@ -230,6 +233,7 @@ void extractToCacheReturnsCachedFileOnSecondCall(@TempDir Path tempDir) throws E @Test void changedNativeVersionDoesNotReuseCachedArtifactsForSameSdkVersion(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader firstLoader = classLoaderWithNativeArtifacts(tempDir.resolve("native-v1"), TEST_CLASSIFIER, "1.0.0", FAKE_BINARY_CONTENT, FAKE_CLI_CONTENT); @@ -241,13 +245,16 @@ void changedNativeVersionDoesNotReuseCachedArtifactsForSameSdkVersion(@TempDir P assertNotEquals(firstRuntime, secondRuntime, "Different native versions must use different cache entries"); assertBytesEqual(FAKE_BINARY_CONTENT, Files.readAllBytes(firstRuntime)); - assertBytesEqual(FAKE_CLI_CONTENT, Files.readAllBytes(firstRuntime.getParent().resolve(TEST_CLI_FILENAME))); + assertBytesEqual(FAKE_CLI_CONTENT, + Files.readAllBytes(firstRuntime.getParent().resolve(NativeRuntimeLoader.CLI_FILENAME))); assertBytesEqual(OTHER_BINARY_CONTENT, Files.readAllBytes(secondRuntime)); - assertBytesEqual(OTHER_CLI_CONTENT, Files.readAllBytes(secondRuntime.getParent().resolve(TEST_CLI_FILENAME))); + assertBytesEqual(OTHER_CLI_CONTENT, + Files.readAllBytes(secondRuntime.getParent().resolve(NativeRuntimeLoader.CLI_FILENAME))); } @Test void extractToCacheThrowsWhenClasspathResourceMissing(@TempDir Path tempDir) { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader emptyLoader = new URLClassLoader(new URL[0], null); @@ -257,6 +264,7 @@ void extractToCacheThrowsWhenClasspathResourceMissing(@TempDir Path tempDir) { @Test void extractToCacheThrowsWhenNativeMetadataMissing(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path resourceDir = tempDir.resolve("native").resolve(TEST_CLASSIFIER); Files.createDirectories(resourceDir); Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME), FAKE_BINARY_CONTENT); @@ -270,6 +278,7 @@ void extractToCacheThrowsWhenNativeMetadataMissing(@TempDir Path tempDir) throws @Test void extractedBinaryContentsMatchClasspathResource(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER); @@ -281,6 +290,7 @@ void extractedBinaryContentsMatchClasspathResource(@TempDir Path tempDir) throws @Test void extractToCacheFiltersClasspathByClassifier(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); writeRuntimeResource(tempDir, TEST_CLASSIFIER, FAKE_BINARY_CONTENT); writeRuntimeResource(tempDir, OTHER_CLASSIFIER, OTHER_BINARY_CONTENT); @@ -294,6 +304,7 @@ void extractToCacheFiltersClasspathByClassifier(@TempDir Path tempDir) throws Ex @Test void extractToCacheRepairsInvalidCacheEntry(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); Path cached = cacheBase.resolve(TEST_VERSION).resolve(TEST_NATIVE_VERSION).resolve(TEST_CLASSIFIER) .resolve(NativeRuntimeLoader.RUNTIME_FILENAME); @@ -309,13 +320,13 @@ void extractToCacheRepairsInvalidCacheEntry(@TempDir Path tempDir) throws Except @Test void nonExecutableCachedCliIsNotAcceptedAsValid(@TempDir Path tempDir) throws Exception { - assumeTrue(!TEST_CLASSIFIER.startsWith("win32")); + assumeLinuxX64(); assumeTrue(Files.getFileStore(tempDir).supportsFileAttributeView("posix")); Path cacheBase = tempDir.resolve("cache"); Path cacheDir = cacheBase.resolve(TEST_VERSION).resolve(TEST_NATIVE_VERSION).resolve(TEST_CLASSIFIER); Files.createDirectories(cacheDir); Files.write(cacheDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME), FAKE_BINARY_CONTENT); - Path cachedCli = Files.write(cacheDir.resolve(TEST_CLI_FILENAME), FAKE_CLI_CONTENT); + Path cachedCli = Files.write(cacheDir.resolve(NativeRuntimeLoader.CLI_FILENAME), FAKE_CLI_CONTENT); Files.setPosixFilePermissions(cachedCli, PosixFilePermissions.fromString("rw-------")); ClassLoader loader = classLoaderWithRuntimeAndCliResources(tempDir, TEST_CLASSIFIER); @@ -330,6 +341,7 @@ void nonExecutableCachedCliIsNotAcceptedAsValid(@TempDir Path tempDir) throws Ex @Test void bundledCliSiblingIsUsedWhenClasspathResourceAbsent(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path bundledCliDir = tempDir.resolve("bundled-cli"); Files.createDirectories(bundledCliDir); Path runtimeNode = bundledCliDir.resolve(NativeRuntimeLoader.RUNTIME_FILENAME); @@ -347,6 +359,7 @@ void bundledCliSiblingIsUsedWhenClasspathResourceAbsent(@TempDir Path tempDir) t @Test void classpathResourceWinsOverBundledCliSibling(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); // Source 3: bundled CLI dir with runtime.node (should NOT win) Path bundledCliDir = tempDir.resolve("bundled-cli"); Files.createDirectories(bundledCliDir); @@ -368,6 +381,7 @@ void classpathResourceWinsOverBundledCliSibling(@TempDir Path tempDir) throws Ex @Test void bundledCliSiblingIsIgnoredWhenRuntimeNodeMissing(@TempDir Path tempDir) { + assumeLinuxX64(); Path bundledCliDir = tempDir.resolve("bundled-cli-no-runtime"); // bundledCliDir doesn't even exist — no runtime.node present @@ -399,12 +413,12 @@ void defaultPublisherMovesSourceToTarget(@TempDir Path tempDir) throws Exception @Test void cliIsExecutableBeforeAtomicPublication(@TempDir Path tempDir) throws Exception { - assumeTrue(!TEST_CLASSIFIER.startsWith("win32")); + assumeLinuxX64(); assumeTrue(Files.getFileStore(tempDir).supportsFileAttributeView("posix")); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeAndCliResources(tempDir, TEST_CLASSIFIER); NativeRuntimeLoader.AtomicPublisher publisher = (temp, cached) -> { - if (cached.getFileName().toString().equals(TEST_CLI_FILENAME)) { + if (cached.getFileName().toString().equals(NativeRuntimeLoader.CLI_FILENAME)) { assertTrue(Files.isExecutable(temp), "CLI temp file must be executable before atomic publication"); } Files.move(temp, cached, StandardCopyOption.REPLACE_EXISTING); @@ -415,6 +429,7 @@ void cliIsExecutableBeforeAtomicPublication(@TempDir Path tempDir) throws Except @Test void extractionCleansUpTempFileWhenPublicationFails(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER); @@ -435,6 +450,7 @@ void extractionCleansUpTempFileWhenPublicationFails(@TempDir Path tempDir) throw @Test void extractionCleansUpTempFileWhenPublisherThrowsIllegalStateException(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER); @@ -462,6 +478,7 @@ void extractionCleansUpTempFileWhenPublisherThrowsIllegalStateException(@TempDir @Test void concurrentExtractionByMultipleThreadsBothSucceed(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER); int threadCount = 8; @@ -499,6 +516,7 @@ void concurrentExtractionByMultipleThreadsBothSucceed(@TempDir Path tempDir) thr @Test void resolveWithNullCliEnvExtractsFromClasspath(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader loader = classLoaderWithRuntimeResource(tempDir, TEST_CLASSIFIER); @@ -509,8 +527,54 @@ void resolveWithNullCliEnvExtractsFromClasspath(@TempDir Path tempDir) throws Ex assertTrue(Files.size(result) > 0); } + @Test + void resolveRuntimeWrapperExtractsAdjacentPairFromAbsentCache(@TempDir Path tempDir) throws Exception { + Path cacheBase = tempDir.resolve("cache"); + assertFalse(Files.exists(cacheBase)); + ClassLoader loader = classLoaderWithRuntimeWrapperArtifacts(tempDir, TEST_CLASSIFIER, TEST_NATIVE_VERSION); + + Path wrapper = NativeRuntimeLoader.resolveRuntimeWrapper(cacheBase, loader, TEST_CLASSIFIER, TEST_VERSION); + + assertEquals(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME, wrapper.getFileName().toString()); + assertTrue(Files.isRegularFile(wrapper)); + assertTrue(Files.isRegularFile(wrapper.resolveSibling(NativeRuntimeLoader.RUNTIME_FILENAME))); + assertFalse(Files.exists(wrapper.resolveSibling(NativeRuntimeLoader.CLI_FILENAME))); + } + + @Test + void resolveRuntimeWrapperExtractsRetainedRuntimeAssets(@TempDir Path tempDir) throws Exception { + Path resourceDir = tempDir.resolve("native").resolve(TEST_CLASSIFIER); + writeRuntimeResource(tempDir, TEST_CLASSIFIER, FAKE_BINARY_CONTENT); + Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME), FAKE_WRAPPER_CONTENT); + Path ripgrep = resourceDir.resolve("ripgrep/bin/linux-x64/rg"); + Files.createDirectories(ripgrep.getParent()); + Files.writeString(ripgrep, "ripgrep"); + Files.writeString(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_ASSETS_FILENAME), + "644\truntime.node\n" + "755\tcopilot-runtime\n" + "755\tripgrep/bin/linux-x64/rg\n"); + ClassLoader loader = new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null); + + Path wrapper = NativeRuntimeLoader.resolveRuntimeWrapper(tempDir.resolve("cache"), loader, TEST_CLASSIFIER, + TEST_VERSION); + + Path installedRipgrep = wrapper.getParent().resolve("ripgrep/bin/linux-x64/rg"); + assertEquals("ripgrep", Files.readString(installedRipgrep)); + assertTrue(Files.isExecutable(installedRipgrep)); + } + + @Test + void resolveRuntimeWrapperRejectsClassifierWithoutWrapper(@TempDir Path tempDir) throws Exception { + writeRuntimeResource(tempDir, TEST_CLASSIFIER, FAKE_BINARY_CONTENT); + ClassLoader loader = new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null); + + IOException error = assertThrows(IOException.class, () -> NativeRuntimeLoader + .resolveRuntimeWrapper(tempDir.resolve("cache"), loader, TEST_CLASSIFIER, TEST_VERSION)); + + assertTrue(error.getMessage().contains(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME)); + } + @Test void resolveThrowsWhenNoSourceIsAvailable(@TempDir Path tempDir) { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader emptyLoader = new URLClassLoader(new URL[0], null); @@ -521,6 +585,7 @@ void resolveThrowsWhenNoSourceIsAvailable(@TempDir Path tempDir) { @Test void resolveFallsBackToRuntimeAlongsideBundledCli(@TempDir Path tempDir) throws Exception { + assumeLinuxX64(); Path cacheBase = tempDir.resolve("cache"); ClassLoader emptyLoader = new URLClassLoader(new URL[0], null); Path bundledCli = tempDir.resolve("copilot"); @@ -538,6 +603,17 @@ void resolveFallsBackToRuntimeAlongsideBundledCli(@TempDir Path tempDir) throws // Helpers // ------------------------------------------------------------------------- + private static void assumeLinuxX64() { + String actualClassifier; + try { + actualClassifier = PlatformDetector.detectClassifier(); + } catch (IllegalStateException ex) { + actualClassifier = "unsupported"; + } + assumeTrue(TEST_CLASSIFIER.equals(actualClassifier), + "Requires linux-x64; detected " + actualClassifier + "; see #2323"); + } + private static ClassLoader classLoaderWithVersionResource(Path tempDir, String version) throws IOException { Path propsFile = tempDir.resolve(NativeRuntimeLoader.VERSION_RESOURCE); Files.writeString(propsFile, "version=" + version + "\n"); @@ -553,7 +629,7 @@ private static ClassLoader classLoaderWithRuntimeAndCliResources(Path tempDir, S throws IOException { writeRuntimeResource(tempDir, classifier, FAKE_BINARY_CONTENT); Path resourceDir = tempDir.resolve("native").resolve(classifier); - Files.write(resourceDir.resolve(TEST_CLI_FILENAME), FAKE_CLI_CONTENT); + Files.write(resourceDir.resolve(NativeRuntimeLoader.CLI_FILENAME), FAKE_CLI_CONTENT); return new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null); } @@ -561,7 +637,19 @@ private static ClassLoader classLoaderWithNativeArtifacts(Path tempDir, String c byte[] runtimeContent, byte[] cliContent) throws IOException { writeRuntimeResource(tempDir, classifier, runtimeContent); Path resourceDir = tempDir.resolve("native").resolve(classifier); - Files.write(resourceDir.resolve(TEST_CLI_FILENAME), cliContent); + Files.write(resourceDir.resolve(NativeRuntimeLoader.CLI_FILENAME), cliContent); + Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME), FAKE_WRAPPER_CONTENT); + Files.writeString(resourceDir.resolve("platform.properties"), + "classifier=" + classifier + "\nversion=" + nativeVersion + "\n"); + return new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null); + } + + private static ClassLoader classLoaderWithRuntimeWrapperArtifacts(Path tempDir, String classifier, + String nativeVersion) throws IOException { + writeRuntimeResource(tempDir, classifier, FAKE_BINARY_CONTENT); + Path resourceDir = tempDir.resolve("native").resolve(classifier); + Files.write(resourceDir.resolve(NativeRuntimeLoader.RUNTIME_WRAPPER_FILENAME), FAKE_WRAPPER_CONTENT); + Files.write(resourceDir.resolve(NativeRuntimeLoader.CLI_FILENAME), FAKE_CLI_CONTENT); Files.writeString(resourceDir.resolve("platform.properties"), "classifier=" + classifier + "\nversion=" + nativeVersion + "\n"); return new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, null); diff --git a/nodejs/README.md b/nodejs/README.md index 53e81c7aa4..5886df234f 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -95,6 +95,7 @@ new CopilotClient(options?: CopilotClientOptions) - `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). There is no top-level `cliUrl` shortcut; use this factory for URL-based connections. - `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Because the runtime shares this process, `env`, `telemetry`, and `workingDirectory` are rejected with this transport; set them on the host process instead. - The child-process transports (`forStdio`/`forTcp`) also accept a per-connection `env`. Set it there or via the top-level `env` option — not both (setting both throws). + - Managed child-process connections materialize the bundled `copilot-runtime` and adjacent `runtime.node`, then launch the wrapper by default. An explicit connection `path` or `COPILOT_CLI_PATH` overrides the bundled runtime. - `mode?: "empty" | "copilot-cli"` - Defaulting strategy. Use `"empty"` for multi-user server mode; defaults to `"copilot-cli"`. - `workingDirectory?: string` - Working directory for the runtime process (default: current process cwd). - `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 13bb9c904e..4f460e9577 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -16,7 +16,7 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { Socket } from "node:net"; -import { dirname, isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createMessageConnection, @@ -43,6 +43,7 @@ import type { import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; +import { materializeRuntimeBundle } from "./runtimeArtifacts.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; import { createCopilotRequestAdapter } from "./copilotRequestHandler.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; @@ -365,16 +366,19 @@ function getCliPlatformPackageNames(): string[] { return variants.map((variant) => `@github/copilot-${variant}-${arch}`); } +interface BundledCliPackage { + root: string; + platform: string; +} + /** - * Gets the path to the bundled CLI from the platform-specific @github/copilot-* - * package. Uses index.js directly rather than the native binary so the CLI runs - * under the current Node.js runtime. + * Resolves the current platform package and its npm prebuilds folder. * * In ESM, uses import.meta.resolve directly. In CJS (e.g., VS Code extensions * bundled with esbuild format:"cjs"), import.meta is empty so we fall back to * walking node_modules to find the package. */ -function getBundledCliPath(): string { +function getBundledCliPackage(): BundledCliPackage { const packageNames = getCliPlatformPackageNames(); if (typeof import.meta.resolve === "function") { @@ -383,7 +387,10 @@ function getBundledCliPath(): string { try { const packageEntryUrl = import.meta.resolve(packageName); const packageEntryPath = fileURLToPath(packageEntryUrl); - return join(dirname(packageEntryPath), "index.js"); + return { + root: dirname(packageEntryPath), + platform: packageName.slice("@github/copilot-".length), + }; } catch { // Try the next candidate platform package. } @@ -400,9 +407,13 @@ function getBundledCliPath(): string { const searchPaths = req.resolve.paths("@github/copilot") ?? []; for (const base of searchPaths) { for (const packageName of packageNames) { - const candidate = join(base, ...packageName.split("/"), "index.js"); + const root = join(base, ...packageName.split("/")); + const candidate = join(root, "index.js"); if (existsSync(candidate)) { - return candidate; + return { + root, + platform: packageName.slice("@github/copilot-".length), + }; } } } @@ -413,6 +424,14 @@ function getBundledCliPath(): string { ); } +function getBundledRuntimePath(): string { + const bundled = getBundledCliPackage(); + return materializeRuntimeBundle({ + packageRoot: bundled.root, + platform: bundled.platform, + }); +} + /** * Main client for interacting with the Copilot CLI. * @@ -520,6 +539,7 @@ export class CopilotClient { private _rpc: ReturnType | null = null; private _internalRpc: ReturnType | null = null; private processExitPromise: Promise | null = null; // Rejects when CLI process exits + private processTransportError: Error | null = null; private negotiatedProtocolVersion: number | null = null; /** Connection-level session filesystem config, set via constructor option. */ private sessionFsConfig: SessionFsConfig | null = null; @@ -733,10 +753,14 @@ export class CopilotClient { conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined; const effectiveEnv = connEnv ?? options.env ?? process.env; this.resolvedEnv = effectiveEnv; - this.resolvedCliPath = - conn.kind === "stdio" || conn.kind === "tcp" - ? (conn.path ?? effectiveEnv.COPILOT_CLI_PATH ?? getBundledCliPath()) - : undefined; + if (conn.kind === "stdio" || conn.kind === "tcp") { + const explicitCliPath = conn.path ?? effectiveEnv.COPILOT_CLI_PATH; + if (explicitCliPath) { + this.resolvedCliPath = explicitCliPath; + } else { + this.resolvedCliPath = getBundledRuntimePath(); + } + } // Collect extra CLI args from the connection variant (if any). const connArgs: readonly string[] = @@ -951,6 +975,8 @@ export class CopilotClient { return; } + this.forceStopping = false; + this.processTransportError = null; this.state = "connecting"; try { @@ -997,8 +1023,10 @@ export class CopilotClient { this.state = "connected"; } catch (error) { + const startupError = this.processTransportError ?? error; + await this.forceStop(); this.state = "error"; - throw error; + throw startupError; } } @@ -2717,27 +2745,27 @@ export class CopilotClient { // Set up a promise that rejects when the process exits (used to race against RPC calls) this.processExitPromise = new Promise((_, rejectProcessExit) => { this.cliProcess!.on("exit", (code) => { - // Give a small delay for stderr to be fully captured - setTimeout(() => { - const stderrOutput = this.stderrBuffer.trim(); - if (stderrOutput) { - rejectProcessExit( - new Error( - `CLI server exited with code ${code}\nstderr: ${stderrOutput}` - ) - ); - } else { - rejectProcessExit( - new Error(`CLI server exited unexpectedly with code ${code}`) - ); - } - }, 50); + if (this.messageWriter) { + this.messageWriter.suppressWriteErrors = true; + } + const stderrOutput = this.stderrBuffer.trim(); + if (stderrOutput) { + rejectProcessExit( + new Error( + `CLI server exited with code ${code}\nstderr: ${stderrOutput}` + ) + ); + } else { + rejectProcessExit( + new Error(`CLI server exited unexpectedly with code ${code}`) + ); + } }); }); // Prevent unhandled rejection when process exits normally (we only use this in Promise.race) this.processExitPromise.catch(() => {}); - this.cliProcess.on("exit", (code) => { + this.cliProcess.on("close", (code) => { if (!resolved) { resolved = true; const stderrOutput = this.stderrBuffer.trim(); @@ -2782,7 +2810,15 @@ export class CopilotClient { /** Starts the in-process FFI runtime with SDK-managed typed options. */ private async startInProcessFfi(): Promise { - const entrypoint = this.resolveCliPathForFfi(); + const explicitEntrypoint = this.resolvedEnv.COPILOT_CLI_PATH; + const runtimeLibrary = explicitEntrypoint + ? join( + dirname(resolve(explicitEntrypoint)), + "prebuilds", + CopilotClient.getNapiPrebuildsFolder(explicitEntrypoint), + "runtime.node" + ) + : join(dirname(getBundledRuntimePath()), "runtime.node"); // Load the FFI host lazily so the native `koffi` addon (and its // platform-specific `koffi.node`) is only loaded on the in-process path; // out-of-process (stdio/tcp) consumers never touch the native dependency. @@ -2817,12 +2853,7 @@ export class CopilotClient { args.push("--remote"); } - const host = FfiRuntimeHost.create( - entrypoint, - CopilotClient.getNapiPrebuildsFolder(entrypoint), - environment, - args - ); + const host = FfiRuntimeHost.create(runtimeLibrary, explicitEntrypoint, environment, args); this.ffiHost = host; await host.start(); } @@ -2845,20 +2876,6 @@ export class CopilotClient { this.connection.listen(); } - /** - * Resolves the CLI entrypoint used for in-process FFI hosting: `COPILOT_CLI_PATH` - * when set, otherwise the bundled platform-package entrypoint. - */ - private resolveCliPathForFfi(): string { - return this.resolvedEnv.COPILOT_CLI_PATH ?? getBundledCliPath(); - } - - /** - * Returns the napi prebuilds folder name for the current host — the - * `-` convention (e.g. `win32-x64`, `darwin-arm64`, - * `linux-x64`, `linuxmusl-x64`) under which the runtime ships - * `prebuilds//runtime.node`. - */ private static getNapiPrebuildsFolder(entrypoint: string): string { const arch = process.arch; if (arch !== "x64" && arch !== "arm64") { @@ -2902,6 +2919,10 @@ export class CopilotClient { } this.state = "error"; const reason = err instanceof Error ? (err.stack ?? err.message) : String(err); + const stderrOutput = this.stderrBuffer.trim(); + this.processTransportError = new Error( + `CLI server connection failed: ${reason}${stderrOutput ? `\nstderr: ${stderrOutput}` : ""}` + ); this.logDebug(`stdin pipe error: ${reason}`); try { this.connection?.dispose(); diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts index a92aa1589a..4795e325ce 100644 --- a/nodejs/src/ffiRuntimeHost.ts +++ b/nodejs/src/ffiRuntimeHost.ts @@ -7,10 +7,8 @@ * and speaking JSON-RPC over its C ABI (FFI) instead of spawning a CLI child process * and communicating over stdio/TCP. * - * The native `host_start` export spawns the CLI worker itself - * (`node --embedded-host` for a `.js` entrypoint, or ` - * --embedded-host` for a packaged binary), so the SDK never launches the worker - * directly. LSP `Content-Length:`-framed JSON-RPC bytes are pumped across the ABI: + * The native `host_start` export constructs the Rust server synchronously in this + * process. LSP `Content-Length:`-framed JSON-RPC bytes are pumped across the ABI: * writes go to `connection_write`; inbound frames arrive on a native callback that * feeds {@link FfiRuntimeHost.receiveStream}. The existing `vscode-jsonrpc` * `StreamMessageReader`/`StreamMessageWriter` handle framing unchanged — this is a @@ -19,7 +17,7 @@ import { existsSync } from "node:fs"; import koffi from "koffi"; -import { dirname, join, resolve } from "node:path"; +import { resolve } from "node:path"; import { PassThrough, Writable } from "node:stream"; const SYMBOL_PREFIX = "copilot_runtime_"; @@ -97,14 +95,12 @@ function loadLibrary(libraryPath: string): FfiLibrary { return loadedLibrary; } -function buildArgvJson(cliEntrypoint: string, args: readonly string[]): Buffer { - // A `.js` entrypoint is launched via node; the packaged single-file CLI binary - // embeds its own Node and is invoked directly. `--no-auto-update` pins the worker - // to the bundled pkg matching the loaded cdylib, instead of drifting to a newer - // version installed under the user's `~/.copilot/pkg` (which would cause ABI skew). - const argv = cliEntrypoint.toLowerCase().endsWith(".js") - ? ["node", cliEntrypoint, "--embedded-host", "--no-auto-update"] - : [cliEntrypoint, "--embedded-host", "--no-auto-update"]; +function buildArgvJson(cliEntrypoint: string | undefined, args: readonly string[]): Buffer { + const argv = cliEntrypoint + ? cliEntrypoint.toLowerCase().endsWith(".js") + ? ["node", cliEntrypoint, "--embedded-host", "--no-auto-update"] + : [cliEntrypoint, "--embedded-host", "--no-auto-update"] + : []; argv.push(...args); return Buffer.from(JSON.stringify(argv), "utf8"); } @@ -140,7 +136,7 @@ export class FfiRuntimeHost { private constructor( private readonly libraryPath: string, - private readonly cliEntrypoint: string, + private readonly cliEntrypoint: string | undefined, private readonly environment: Record | undefined, private readonly args: readonly string[] ) { @@ -161,41 +157,38 @@ export class FfiRuntimeHost { } /** - * Resolves the cdylib next to the given CLI entrypoint and prepares the FFI host. - * The cdylib is resolved as `prebuilds//runtime.node` relative to - * the entrypoint directory (the napi-rs `-` layout, e.g. - * `linux-x64`). Throws if it cannot be found. + * Loads the runtime cdylib at the given path and prepares the FFI host. */ static create( - cliEntrypoint: string, - prebuildsFolder: string, + libraryPath: string, + cliEntrypoint: string | undefined, environment: Record | undefined, args: readonly string[] ): FfiRuntimeHost { - const fullEntrypoint = resolve(cliEntrypoint); - const distDir = dirname(fullEntrypoint); - const libraryPath = join(distDir, "prebuilds", prebuildsFolder, "runtime.node"); - if (!existsSync(libraryPath)) { - throw new Error(`FFI runtime library not found. Looked for '${libraryPath}'.`); + const fullLibraryPath = resolve(libraryPath); + if (!existsSync(fullLibraryPath)) { + throw new Error(`FFI runtime library not found at '${fullLibraryPath}'.`); } - return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args); + return new FfiRuntimeHost( + fullLibraryPath, + cliEntrypoint ? resolve(cliEntrypoint) : undefined, + environment, + args + ); } - /** - * Starts the in-process runtime: spawns the CLI worker via the native host, - * waits for readiness, and opens the FFI JSON-RPC connection. - */ + /** Starts the in-process Rust runtime and opens the FFI JSON-RPC connection. */ async start(): Promise { const argvJson = buildArgvJson(this.cliEntrypoint, this.args); const envJson = buildEnvJson(this.environment); - // The native host spawns the CLI worker itself and has no cwd parameter, so the - // worker inherits this process's cwd. A custom working directory is intentionally + // The native host has no cwd parameter, so it uses this process's cwd. A custom + // working directory is intentionally // unsupported for the in-process transport (rejected by the client constructor) // rather than mutating the shared process-global cwd here. - // host_start blocks until the worker connects back and signals readiness - // (up to ~30s); run it as an async FFI call so the Node event loop isn't blocked. + // host_start constructs the native engine synchronously; run it as an async FFI + // call so the Node event loop isn't blocked. this.serverId = await new Promise((resolvePromise, rejectPromise) => { this.lib.hostStart.async( argvJson, @@ -212,9 +205,7 @@ export class FfiRuntimeHost { ); }); if (!this.serverId) { - throw new Error( - `copilot_runtime_host_start failed (library '${this.libraryPath}', entrypoint '${this.cliEntrypoint}').` - ); + throw new Error(`copilot_runtime_host_start failed (library '${this.libraryPath}').`); } this.outboundCallback = koffi.register( diff --git a/nodejs/src/runtimeArtifacts.ts b/nodejs/src/runtimeArtifacts.ts new file mode 100644 index 0000000000..19d9926250 --- /dev/null +++ b/nodejs/src/runtimeArtifacts.ts @@ -0,0 +1,183 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + renameSync, + rmSync, + statSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, relative, sep } from "node:path"; + +export interface RuntimeArtifactSources { + packageRoot: string; + platform: string; +} + +const EXCLUDED_TOP_LEVEL = new Set([ + "app.js", + "assets", + "changelog.json", + "copilot", + "copilot.exe", + "copilot-sdk", + "foundry-local-sdk", + "index.js", + "LICENSE.md", + "napi-oop-runtime", + "npm-loader.js", + "package.json", + "preloads", + "pvrecorder", + "queries", + "README.md", + "sdk", + "sea-loader.js", + "webview", +]); + +interface RuntimeAsset { + source: string; + relativePath: string; +} + +function validateFile(path: string, label: string): void { + if (!existsSync(path)) { + throw new Error(`${label} not found at ${path}.`); + } + if (statSync(path).size === 0) { + throw new Error(`${label} at ${path} is empty.`); + } +} + +function validateRuntimeBundle(wrapper: string, runtimeNode: string): void { + validateFile(wrapper, "Copilot runtime wrapper"); + validateFile(runtimeNode, "Copilot runtime.node"); +} + +function isExcluded(relativePath: string): boolean { + const parts = relativePath.split(sep); + const topLevel = parts[0]; + const fileName = parts.at(-1) ?? ""; + return ( + EXCLUDED_TOP_LEVEL.has(topLevel) || + /^tree-sitter.*\.wasm$/.test(topLevel) || + /^voice-.*\.js$/.test(topLevel) || + fileName === "cli-native.node" || + parts.includes("mediaremote-adapter") || + fileName.startsWith("copilot-runtime-bin") + ); +} + +function collectRuntimeAssets(sources: RuntimeArtifactSources): RuntimeAsset[] { + const assets: RuntimeAsset[] = []; + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const source = join(directory, entry.name); + const sourceRelative = relative(sources.packageRoot, source); + if (isExcluded(sourceRelative)) { + continue; + } + if (entry.isDirectory()) { + visit(source); + continue; + } + if (!entry.isFile() && !entry.isSymbolicLink()) { + continue; + } + + const parts = sourceRelative.split(sep); + let relativePath = sourceRelative; + if (parts[0] === "prebuilds") { + if (parts[1] !== sources.platform || parts.length < 3) { + continue; + } + relativePath = parts.slice(2).join(sep); + } + assets.push({ source, relativePath }); + } + }; + visit(sources.packageRoot); + return assets.sort((left, right) => left.relativePath.localeCompare(right.relativePath)); +} + +function sourceFingerprint(assets: RuntimeAsset[]): string { + const hash = createHash("sha256"); + for (const asset of assets) { + const stat = lstatSync(asset.source); + hash.update(asset.relativePath).update("\0"); + hash.update(`${stat.size}:${stat.mtimeMs}`).update("\0"); + } + return hash.digest("hex").slice(0, 20); +} + +function makeExecutable(path: string): void { + if (process.platform === "win32") { + return; + } + const mode = statSync(path).mode; + if ((mode & 0o111) === 0) { + chmodSync(path, mode | 0o111); + } +} + +export function defaultRuntimeCacheRoot( + platform = process.platform, + home = homedir(), + environment: NodeJS.ProcessEnv = process.env +): string { + const cacheDirectory = + platform === "win32" + ? (environment.LOCALAPPDATA ?? join(home, "AppData", "Local")) + : platform === "darwin" + ? join(home, "Library", "Caches") + : (environment.XDG_CACHE_HOME ?? join(home, ".cache")); + return join(cacheDirectory, "github-copilot-sdk", "runtime"); +} + +export function materializeRuntimeBundle( + sources: RuntimeArtifactSources, + cacheRoot = defaultRuntimeCacheRoot() +): string { + const assets = collectRuntimeAssets(sources); + const wrapperName = process.platform === "win32" ? "copilot-runtime.exe" : "copilot-runtime"; + const sourceWrapper = assets.find((asset) => asset.relativePath === wrapperName)?.source; + const sourceRuntimeNode = assets.find((asset) => asset.relativePath === "runtime.node")?.source; + validateRuntimeBundle(sourceWrapper ?? "", sourceRuntimeNode ?? ""); + + const installDir = join(cacheRoot, `${sources.platform}-${sourceFingerprint(assets)}`); + const installedWrapper = join(installDir, wrapperName); + const installedRuntimeNode = join(installDir, "runtime.node"); + if (existsSync(installDir)) { + validateRuntimeBundle(installedWrapper, installedRuntimeNode); + makeExecutable(installedWrapper); + return installedWrapper; + } + + mkdirSync(cacheRoot, { recursive: true }); + const stagingDir = mkdtempSync(join(cacheRoot, ".runtime-")); + try { + for (const asset of assets) { + const destination = join(stagingDir, asset.relativePath); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(asset.source, destination); + } + const stagedWrapper = join(stagingDir, wrapperName); + makeExecutable(stagedWrapper); + renameSync(stagingDir, installDir); + } catch (error) { + if (!existsSync(installDir)) { + throw error; + } + validateRuntimeBundle(installedWrapper, installedRuntimeNode); + } finally { + rmSync(stagingDir, { recursive: true, force: true }); + } + + return installedWrapper; +} diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 5baff82cb4..4017d00637 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -60,6 +60,28 @@ describe("approveAll", () => { }); describe("CopilotClient", () => { + it.each([ + { + source: "connection path", + connection: RuntimeConnection.forStdio({ path: "/explicit/copilot" }), + env: {}, + expected: "/explicit/copilot", + }, + { + source: "COPILOT_CLI_PATH", + connection: RuntimeConnection.forStdio(), + env: { COPILOT_CLI_PATH: "/environment/copilot" }, + expected: "/environment/copilot", + }, + ])( + "preserves explicit child-process override from $source", + ({ connection, env, expected }) => { + const client = new CopilotClient({ connection, env }); + + expect((client as any).resolvedCliPath).toBe(expected); + } + ); + async function startWithMockConnection( builtinPluginDirectories?: readonly string[] ): Promise> { diff --git a/nodejs/test/e2e/builtin_tools.e2e.test.ts b/nodejs/test/e2e/builtin_tools.e2e.test.ts index 36b70ea195..39900bc7d6 100644 --- a/nodejs/test/e2e/builtin_tools.e2e.test.ts +++ b/nodejs/test/e2e/builtin_tools.e2e.test.ts @@ -130,6 +130,19 @@ describe("Built-in Tools", async () => { async () => { await writeFile(join(workDir, "data.txt"), "apple\nbanana\napricot\ncherry\n"); const session = await client.createSession({ onPermissionRequest: approveAll }); + let grepToolCallId: string | undefined; + let grepCompletedSuccessfully = false; + session.on((event) => { + if (event.type === "tool.execution_start" && event.data.toolName === "grep") { + grepToolCallId = event.data.toolCallId; + } else if ( + event.type === "tool.execution_complete" && + event.data.toolCallId === grepToolCallId && + event.data.success + ) { + grepCompletedSuccessfully = true; + } + }); const msg = await session.sendAndWait( { prompt: "Search for lines starting with 'ap' in the file 'data.txt'. Tell me which lines matched.", @@ -138,6 +151,7 @@ describe("Built-in Tools", async () => { ); expect(msg?.data.content).toContain("apple"); expect(msg?.data.content).toContain("apricot"); + expect(grepCompletedSuccessfully).toBe(true); }, TEST_TIMEOUT_MS ); diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index 35e7440766..bc3421bfa1 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -184,21 +184,15 @@ describe("Client", () => { await client.stop(); }); - it("should report error with stderr when CLI fails to start", async () => { + it.skipIf(isInProcessTransport)("should report error when CLI fails to start", async () => { const client = new CopilotClient({ - connection: RuntimeConnection.forStdio({ args: ["--nonexistent-flag-for-testing"] }), + connection: RuntimeConnection.forStdio({ + args: ["--nonexistent-flag-for-testing"], + }), }); onTestFinishedStop(client); - let initialError: Error | undefined; - try { - await client.start(); - expect.fail("Expected start() to throw an error"); - } catch (error) { - initialError = error as Error; - expect(initialError.message).toContain("stderr"); - expect(initialError.message).toContain("nonexistent"); - } + await expect(client.start()).rejects.toBeInstanceOf(Error); // Verify subsequent calls also fail (don't hang) try { @@ -206,7 +200,7 @@ describe("Client", () => { await session.send("test"); expect.fail("Expected send() to throw an error after CLI exit"); } catch (error) { - expect((error as Error).message).toContain("Connection is closed"); + expect(error).toBeInstanceOf(Error); } }); }); diff --git a/nodejs/test/e2e/extension_env_access.e2e.test.ts b/nodejs/test/e2e/extension_env_access.e2e.test.ts index f034db9051..38210a22fb 100644 --- a/nodejs/test/e2e/extension_env_access.e2e.test.ts +++ b/nodejs/test/e2e/extension_env_access.e2e.test.ts @@ -14,9 +14,9 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; -import { approveAll } from "../../src/index.js"; +import { approveAll, RuntimeConnection } from "../../src/index.js"; import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js"; -import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, getLegacyCliPathForTests } from "./harness/sdkTestContext.js"; import { retry } from "./harness/sdkTestHelper.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -184,56 +184,47 @@ it("ignores a granted variable the extension never requested", async () => { expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_SMUGGLED="); }); -const cliObservations = isInProcessTransport - ? "" - : mkdtempSync(join(tmpdir(), "copilot-env-access-cli-")); +const cliObservations = mkdtempSync(join(tmpdir(), "copilot-env-access-cli-")); const cliResultFile = join(cliObservations, "result"); -const cliContext = isInProcessTransport - ? undefined - : await createSdkTestContext({ - copilotClientOptions: { - env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", - EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", - EXTENSION_RESULT_FILE: cliResultFile, - EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), - EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), - }, - }, - }); +const cliContext = await createSdkTestContext({ + copilotClientOptions: { + connection: RuntimeConnection.forStdio({ path: getLegacyCliPathForTests() }), + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", + EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", + EXTENSION_RESULT_FILE: cliResultFile, + EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), + EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), + }, + }, +}); // The released CLI ignores `requestedEnvironmentVariables`, so this covers the // half a real CLI can prove today: asking for variables does not break the join. // It becomes the grant test once `@github/copilot` carries the host half. -it.skipIf(isInProcessTransport)( - "joins a real CLI that does not support environment requests", - async () => { - if (!cliContext) { - throw new Error("Extension E2E requires an out-of-process transport"); - } - const { workDir, copilotClient } = cliContext; - const extensionDir = join(workDir, ".github", "extensions", "env-access"); - await rm(join(workDir, ".github"), { recursive: true, force: true }); - await rm(cliResultFile, { force: true }); - await mkdir(extensionDir, { recursive: true }); - await copyFile(FIXTURE, join(extensionDir, "extension.mjs")); - execFileSync("git", ["init", "--quiet"], { cwd: workDir }); - - await using _session = await copilotClient.createSession({ - requestExtensions: true, - extensionSdkPath: DIST_DIR, - onPermissionRequest: approveAll, - }); +it("joins a real CLI that does not support environment requests", async () => { + const { workDir, copilotClient } = cliContext; + const extensionDir = join(workDir, ".github", "extensions", "env-access"); + await rm(join(workDir, ".github"), { recursive: true, force: true }); + await rm(cliResultFile, { force: true }); + await mkdir(extensionDir, { recursive: true }); + await copyFile(FIXTURE, join(extensionDir, "extension.mjs")); + execFileSync("git", ["init", "--quiet"], { cwd: workDir }); + + await using _session = await copilotClient.createSession({ + requestExtensions: true, + extensionSdkPath: DIST_DIR, + onPermissionRequest: approveAll, + }); - await retry( - "wait for the env-access extension to join the session", - async () => { - expect(existsSync(cliResultFile)).toBe(true); - }, - 300, - 100 - ); + await retry( + "wait for the env-access extension to join the session", + async () => { + expect(existsSync(cliResultFile)).toBe(true); + }, + 300, + 100 + ); - expect(readFileSync(cliResultFile, "utf-8")).toBe("joined"); - } -); + expect(readFileSync(cliResultFile, "utf-8")).toBe("joined"); +}); diff --git a/nodejs/test/e2e/factory.e2e.test.ts b/nodejs/test/e2e/factory.e2e.test.ts index 669a1f9c64..2bf3ff17fb 100644 --- a/nodejs/test/e2e/factory.e2e.test.ts +++ b/nodejs/test/e2e/factory.e2e.test.ts @@ -4,30 +4,25 @@ import { copyFile, mkdir, rm } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, it, vi } from "vitest"; -import { approveAll, FactoryResumeError } from "../../src/index.js"; +import { approveAll, FactoryResumeError, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN, - isInProcessTransport, + getLegacyCliPathForTests, } from "./harness/sdkTestContext.js"; import { retry } from "./harness/sdkTestHelper.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const factoryTestContext = isInProcessTransport - ? undefined - : await createSdkTestContext({ - copilotClientOptions: { - env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", - }, - }, - }); +const factoryTestContext = await createSdkTestContext({ + copilotClientOptions: { + connection: RuntimeConnection.forStdio({ path: getLegacyCliPathForTests() }), + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", + }, + }, +}); async function setupFactoryExtension(workDir: string, onPermissionRequest = approveAll) { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { copilotClient, openAiEndpoint } = factoryTestContext; const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); const readyFile = join(extensionDir, "ready"); @@ -73,26 +68,20 @@ async function setupFactoryExtension(workDir: string, onPermissionRequest = appr return session; } -it.skipIf(isInProcessTransport)( - "runs an extension-authored factory across the SDK process boundary", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const result = await session.factory.run("argument-echo", { - args: { source: "sdk-e2e", count: 11 }, - notifyOnComplete: false, - }); +it("runs an extension-authored factory across the SDK process boundary", async () => { + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); - expect(result).toMatchObject({ - status: "completed", - result: { source: "sdk-e2e", count: 11 }, - }); - } -); + const result = await session.factory.run("argument-echo", { + args: { source: "sdk-e2e", count: 11 }, + notifyOnComplete: false, + }); + + expect(result).toMatchObject({ + status: "completed", + result: { source: "sdk-e2e", count: 11 }, + }); +}); // TODO(cli-1.0.81-2): the subagent request is rejected downstream under CLI 1.0.81-2, so the // fixture reports didThrow: true. Re-enable once the runtime fix ships. @@ -114,93 +103,78 @@ it.skip("forwards every declared subagent option to the runtime", async () => { }); }, 60_000); -it.skipIf(isInProcessTransport)( - "throws FactoryResumeError with not_found for an unknown run", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const error = await session.factory - .resume("00000000-0000-0000-0000-000000000000") - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(FactoryResumeError); - expect((error as FactoryResumeError).code).toBe("not_found"); - } -); - -it.skipIf(isInProcessTransport)( - "throws FactoryResumeError with non_resumable for a completed run", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const run = await session.factory.run("argument-echo", { notifyOnComplete: false }); - const error = await session.factory.resume(run.runId).catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(FactoryResumeError); - expect((error as FactoryResumeError).code).toBe("non_resumable"); - } -); - -it.skipIf(isInProcessTransport)( - "forwards factory runtime controls across the SDK process boundary", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const suppressed = await session.factory.run("phased", { - notifyOnComplete: false, - logPhaseNames: false, - }); - - expect(suppressed).toMatchObject({ - status: "completed", - result: "finished", - }); - const progress = await session.factory.getRunProgress(suppressed.runId); - expect(progress.records).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "phase", text: "Collect" }), - expect.objectContaining({ kind: "log", text: "Collected" }), - expect.objectContaining({ kind: "phase", text: "Summarize" }), - expect.objectContaining({ kind: "log", text: "Summarized" }), - ]) - ); - - const events = await session.getEvents(); - expect( - events.some( - (event) => - event.type === "system.notification" && - event.data.kind.type === "factory_completed" && - event.data.kind.runId === suppressed.runId - ) - ).toBe(false); - expect( - events.filter( - (event) => event.type === "session.info" && event.data.infoType === "factory_phase" - ) - ).toEqual([]); +it("throws FactoryResumeError with not_found for an unknown run", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); } -); + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const error = await session.factory + .resume("00000000-0000-0000-0000-000000000000") + .catch((caught: unknown) => caught); -it.skipIf(isInProcessTransport)("pages factory runs and returns cursor metadata", async () => { + expect(error).toBeInstanceOf(FactoryResumeError); + expect((error as FactoryResumeError).code).toBe("not_found"); +}); + +it("throws FactoryResumeError with non_resumable for a completed run", async () => { if (!factoryTestContext) { throw new Error("Factory E2E requires the stdio transport"); } const { workDir } = factoryTestContext; await using session = await setupFactoryExtension(workDir); + const run = await session.factory.run("argument-echo", { notifyOnComplete: false }); + const error = await session.factory.resume(run.runId).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(FactoryResumeError); + expect((error as FactoryResumeError).code).toBe("non_resumable"); +}); + +it("forwards factory runtime controls across the SDK process boundary", async () => { + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const suppressed = await session.factory.run("phased", { + notifyOnComplete: false, + logPhaseNames: false, + }); + + expect(suppressed).toMatchObject({ + status: "completed", + result: "finished", + }); + const progress = await session.factory.getRunProgress(suppressed.runId); + expect(progress.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "phase", text: "Collect" }), + expect.objectContaining({ kind: "log", text: "Collected" }), + expect.objectContaining({ kind: "phase", text: "Summarize" }), + expect.objectContaining({ kind: "log", text: "Summarized" }), + ]) + ); + + const events = await session.getEvents(); + expect( + events.some( + (event) => + event.type === "system.notification" && + event.data.kind.type === "factory_completed" && + event.data.kind.runId === suppressed.runId + ) + ).toBe(false); + expect( + events.filter( + (event) => event.type === "session.info" && event.data.infoType === "factory_phase" + ) + ).toEqual([]); +}); + +it("pages factory runs and returns cursor metadata", async () => { + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + const first = await session.factory.run("argument-echo", { args: { ordinal: 1 }, notifyOnComplete: false, @@ -244,178 +218,156 @@ it.skipIf(isInProcessTransport)("pages factory runs and returns cursor metadata" }); }); -it.skipIf(isInProcessTransport)( - "runs a factory when its session denies every permission request", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); - await using session = await setupFactoryExtension(workDir, denyPermissions); - - await expect( - session.factory.run("argument-echo", { notifyOnComplete: false }) - ).resolves.toMatchObject({ - status: "completed", - }); - expect(denyPermissions).not.toHaveBeenCalled(); +it("runs a factory when its session denies every permission request", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); } -); - -it.skipIf(isInProcessTransport)( - "resumes a failed factory when its session denies every permission request", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); - await using session = await setupFactoryExtension(workDir, denyPermissions); - - const failedRun = await session.factory.run("fails-once", { notifyOnComplete: false }); - expect(failedRun).toMatchObject({ - status: "error", - }); - - await expect( - session.factory.resume(failedRun.runId, { - notifyOnComplete: false, - logPhaseNames: false, - }) - ).resolves.toMatchObject({ - status: "completed", - result: "resumed", - }); - expect(denyPermissions).not.toHaveBeenCalled(); + const { workDir } = factoryTestContext; + const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); + await using session = await setupFactoryExtension(workDir, denyPermissions); + + await expect( + session.factory.run("argument-echo", { notifyOnComplete: false }) + ).resolves.toMatchObject({ + status: "completed", + }); + expect(denyPermissions).not.toHaveBeenCalled(); +}); + +it("resumes a failed factory when its session denies every permission request", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); } -); - -it.skipIf(isInProcessTransport)( - "refuses a factory started through the context session from a factory body", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const result = await session.factory.run("starts-from-context-session", { + const { workDir } = factoryTestContext; + const denyPermissions = vi.fn(() => ({ kind: "reject" as const })); + await using session = await setupFactoryExtension(workDir, denyPermissions); + + const failedRun = await session.factory.run("fails-once", { notifyOnComplete: false }); + expect(failedRun).toMatchObject({ + status: "error", + }); + + await expect( + session.factory.resume(failedRun.runId, { notifyOnComplete: false, - }); + logPhaseNames: false, + }) + ).resolves.toMatchObject({ + status: "completed", + result: "resumed", + }); + expect(denyPermissions).not.toHaveBeenCalled(); +}); - expect(result).toMatchObject({ - status: "completed", - result: expect.stringContaining("factory.run and factory.resume"), - }); - expect((result as { result: string }).result).toContain("factory body"); +it("refuses a factory started through the context session from a factory body", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); } -); - -it.skipIf(isInProcessTransport)( - "refuses a factory started through the module session from a factory body", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const result = await session.factory.run("starts-from-module-session", { - notifyOnComplete: false, - }); + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const result = await session.factory.run("starts-from-context-session", { + notifyOnComplete: false, + }); + + expect(result).toMatchObject({ + status: "completed", + result: expect.stringContaining("factory.run and factory.resume"), + }); + expect((result as { result: string }).result).toContain("factory body"); +}); - expect(result).toMatchObject({ - status: "completed", - result: expect.stringContaining("factory.run and factory.resume"), - }); - expect((result as { result: string }).result).toContain("factory body"); +it("refuses a factory started through the module session from a factory body", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); } -); - -it.skipIf(isInProcessTransport)( - "allows a module-level extension watcher to start a factory while another body is parked", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); - await using session = await setupFactoryExtension(workDir); - - const parked = session.factory.run("parked", { notifyOnComplete: false }); - await retry( - "wait for the parked factory to enter its body", - async () => { - expect(existsSync(join(extensionDir, "entered"))).toBe(true); - }, - 100, - 100 - ); - - writeFileSync(join(extensionDir, "start-b"), "start"); - const bResultFile = join(extensionDir, "b-result"); - await retry( - "wait for the module-level watcher factory run to succeed", - async () => { - expect(existsSync(bResultFile)).toBe(true); - expect(JSON.parse(readFileSync(bResultFile, "utf8"))).toMatchObject({ - status: "success", - result: { - status: "completed", - result: { source: "module-watcher" }, - }, - }); - }, - 100, - 100 - ); - - writeFileSync(join(extensionDir, "release"), "release"); - await expect(parked).resolves.toMatchObject({ - status: "completed", - result: "released", - }); - }, - 60_000 -); - -it.skipIf(isInProcessTransport)( - "returns an array result from an extension-authored factory", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const result = await session.factory.run("array-result", { notifyOnComplete: false }); - - expect(result).toMatchObject({ - status: "completed", - result: [1, "two", false], - }); + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const result = await session.factory.run("starts-from-module-session", { + notifyOnComplete: false, + }); + + expect(result).toMatchObject({ + status: "completed", + result: expect.stringContaining("factory.run and factory.resume"), + }); + expect((result as { result: string }).result).toContain("factory body"); +}); + +it("allows a module-level extension watcher to start a factory while another body is parked", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); } -); - -it.skipIf(isInProcessTransport)( - "passes array factory arguments across the SDK process boundary", - async () => { - if (!factoryTestContext) { - throw new Error("Factory E2E requires the stdio transport"); - } - const { workDir } = factoryTestContext; - await using session = await setupFactoryExtension(workDir); - - const args = [1, "two", false]; - const result = await session.factory.run("argument-echo", { - args, - notifyOnComplete: false, - }); + const { workDir } = factoryTestContext; + const extensionDir = join(workDir, ".github", "extensions", "factory-smoke"); + await using session = await setupFactoryExtension(workDir); + + const parked = session.factory.run("parked", { notifyOnComplete: false }); + await retry( + "wait for the parked factory to enter its body", + async () => { + expect(existsSync(join(extensionDir, "entered"))).toBe(true); + }, + 100, + 100 + ); - expect(result).toMatchObject({ - status: "completed", - result: args, - }); + writeFileSync(join(extensionDir, "start-b"), "start"); + const bResultFile = join(extensionDir, "b-result"); + await retry( + "wait for the module-level watcher factory run to succeed", + async () => { + expect(existsSync(bResultFile)).toBe(true); + expect(JSON.parse(readFileSync(bResultFile, "utf8"))).toMatchObject({ + status: "success", + result: { + status: "completed", + result: { source: "module-watcher" }, + }, + }); + }, + 100, + 100 + ); + + writeFileSync(join(extensionDir, "release"), "release"); + await expect(parked).resolves.toMatchObject({ + status: "completed", + result: "released", + }); +}, 60_000); + +it("returns an array result from an extension-authored factory", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); + } + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const result = await session.factory.run("array-result", { notifyOnComplete: false }); + + expect(result).toMatchObject({ + status: "completed", + result: [1, "two", false], + }); +}); + +it("passes array factory arguments across the SDK process boundary", async () => { + if (!factoryTestContext) { + throw new Error("Factory E2E requires the stdio transport"); } -); + const { workDir } = factoryTestContext; + await using session = await setupFactoryExtension(workDir); + + const args = [1, "two", false]; + const result = await session.factory.run("argument-echo", { + args, + notifyOnComplete: false, + }); + + expect(result).toMatchObject({ + status: "completed", + result: args, + }); +}); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index bf62db4826..58c275c800 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -50,6 +50,29 @@ function getCliPathForTests(): string | undefined { return undefined; } +function getCliPlatformPackageNames(): string[] { + const variants = + process.platform === "linux" + ? process.report?.getReport().header.glibcVersionRuntime + ? ["linux", "linuxmusl"] + : ["linuxmusl", "linux"] + : [process.platform]; + return variants.map((variant) => `@github/copilot-${variant}-${process.arch}`); +} + +/** Resolves the legacy SEA only for tests that explicitly exercise Node-hosted features. */ +export function getLegacyCliPathForTests(): string { + const cliName = process.platform === "win32" ? "copilot.exe" : "copilot"; + const githubModules = resolve(__dirname, "../../../node_modules/@github"); + for (const packageName of getCliPlatformPackageNames()) { + const cliPath = join(githubModules, packageName.slice("@github/".length), cliName); + if (fs.existsSync(cliPath)) { + return cliPath; + } + } + throw new Error("Legacy Copilot CLI binary not found in the installed platform package."); +} + export async function createSdkTestContext({ logLevel, useStdio, @@ -286,8 +309,13 @@ export async function createSdkTestContext({ process.chdir(restoreCwd); restoreCwd = undefined; } - // Empty directories but leave them in place for next test - await rimraf([join(homeDir, "*"), join(workDir, "*")], { glob: true }); + // The in-process runtime retains open state files until afterAll shuts it down. + // Keep its isolated home intact while it is alive; removing open files on POSIX + // can leave later tests using unlinked database state. + const cleanupPaths = isInProcess + ? [join(workDir, "*")] + : [join(homeDir, "*"), join(workDir, "*")]; + await rimraf(cleanupPaths, { glob: true }); }); afterAll(async () => { diff --git a/nodejs/test/e2e/inprocess_ffi.e2e.test.ts b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts index af879ea77b..e3b5f75ee4 100644 --- a/nodejs/test/e2e/inprocess_ffi.e2e.test.ts +++ b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts @@ -11,9 +11,8 @@ describe("In-process FFI transport", () => { // exercised by the full E2E suite running under the `inprocess` CI matrix cell, // not a dedicated test. it("should start and connect over in-process FFI", async () => { - // In-process FFI hosting resolves the CLI entrypoint (COPILOT_CLI_PATH or the - // bundled platform package) and its sibling native runtime library itself. If - // neither is available, start() throws and the test fails hard. + // In-process FFI hosting loads runtime.node directly from the bundled runtime. + // If it is unavailable, start() throws and the test fails hard. const client = new CopilotClient({ connection: RuntimeConnection.forInProcess() }); await client.start(); diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index 85abc3a900..7c2906c7b9 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -13,6 +13,7 @@ import type { PermissionRequestResult, } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; const PENDING_WORK_TIMEOUT_MS = 60_000; const TEST_TIMEOUT_MS = 180_000; @@ -516,7 +517,46 @@ describe("Pending work resume", async () => { ).toBe("beta"); if (scenario.disconnectOriginalClient) { - await suspendedClient.forceStop(); + const lockObserver = new CopilotClient({ + workingDirectory: workDir, + env, + gitHubToken: DEFAULT_GITHUB_TOKEN, + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH, + }), + }); + try { + await lockObserver.start(); + await waitForCondition( + async () => { + const result = await lockObserver.rpc.sessions.checkInUse({ + sessionIds: [sessionId], + }); + return result.inUse.includes(sessionId); + }, + { + timeoutMs: PENDING_WORK_TIMEOUT_MS, + timeoutMessage: `Timed out waiting for session '${sessionId}' to acquire its lock.`, + } + ); + + await suspendedClient.forceStop(); + + await waitForCondition( + async () => { + const result = await lockObserver.rpc.sessions.checkInUse({ + sessionIds: [sessionId], + }); + return !result.inUse.includes(sessionId); + }, + { + timeoutMs: PENDING_WORK_TIMEOUT_MS, + timeoutMessage: `Timed out waiting for session '${sessionId}' to release its lock.`, + } + ); + } finally { + await lockObserver.forceStop(); + } } const resumedClient = createConnectingClient(cliUrl); diff --git a/nodejs/test/e2e/rewind.e2e.test.ts b/nodejs/test/e2e/rewind.e2e.test.ts index 49c2b3b8f0..c64fe83145 100644 --- a/nodejs/test/e2e/rewind.e2e.test.ts +++ b/nodejs/test/e2e/rewind.e2e.test.ts @@ -2,13 +2,15 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; const FILE_NAME = "rewind-sdk.txt"; +const ORIGINAL_FILE_CONTENT = "Original rewind content"; +const PREPARED_FILE_CONTENT = "Prepared rewind content"; const FILE_CONTENT = "SDK rewind content"; function expectSamePath(actual: string, expected: string): void { @@ -24,66 +26,73 @@ function expectSamePath(actual: string, expected: string): void { describe("Rewind", async () => { const { copilotClient: client, workDir } = await createSdkTestContext(); - // TODO(cli-1.0.81): Re-enable when Windows file-change tracking records built-in create tool writes. - it.skipIf(process.platform === "win32")( - "should restore tracked file and conversation", - async () => { - const filePath = join(workDir, FILE_NAME); - const session = await client.createSession({ - model: "claude-sonnet-4.5", - enableFileChangeTracking: true, - onPermissionRequest: approveAll, + it("should restore tracked file and conversation", async () => { + const filePath = join(workDir, FILE_NAME); + writeFileSync(filePath, ORIGINAL_FILE_CONTENT); + const session = await client.createSession({ + model: "claude-sonnet-4.5", + enableFileChangeTracking: true, + onPermissionRequest: approveAll, + }); + + try { + const ready = await session.sendAndWait({ + prompt: `Use the edit tool to replace the exact contents of ${FILE_NAME} from ${ORIGINAL_FILE_CONTENT} to ${PREPARED_FILE_CONTENT}. After the tool succeeds, reply with exactly SDK_REWIND_READY.`, }); + expect(ready?.data.content).toBe("SDK_REWIND_READY"); + expect(readFileSync(filePath, "utf8")).toBe(PREPARED_FILE_CONTENT); - try { - const response = await session.sendAndWait({ - prompt: `Use the create tool to create ${FILE_NAME} containing exactly ${FILE_CONTENT}. After the tool succeeds, reply with exactly SDK_REWIND_DONE.`, - }); + const response = await session.sendAndWait({ + prompt: `Use the edit tool to replace the exact contents of ${FILE_NAME} from ${PREPARED_FILE_CONTENT} to ${FILE_CONTENT}. After the tool succeeds, reply with exactly SDK_REWIND_DONE.`, + }); - expect(response?.data.content).toBe("SDK_REWIND_DONE"); - expect(existsSync(filePath)).toBe(true); - expect(readFileSync(filePath, "utf8")).toBe(FILE_CONTENT); + expect(response?.data.content).toBe("SDK_REWIND_DONE"); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, "utf8")).toBe(FILE_CONTENT); - let rewindPoints = await session.rpc.history.listRewindPoints(); - const deadline = Date.now() + 30_000; - while ( - Date.now() < deadline && - (rewindPoints.unavailableReason !== undefined || - !rewindPoints.points[0]?.canRestoreFiles) - ) { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); - rewindPoints = await session.rpc.history.listRewindPoints(); - } + let rewindPoints = await session.rpc.history.listRewindPoints(); + const deadline = Date.now() + 30_000; + while ( + Date.now() < deadline && + (rewindPoints.unavailableReason !== undefined || + rewindPoints.points.length !== 2 || + !rewindPoints.points[1]?.turnChangedFiles || + !rewindPoints.points[1]?.canRestoreFiles) + ) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + rewindPoints = await session.rpc.history.listRewindPoints(); + } - expect(rewindPoints.unavailableReason).toBeUndefined(); - expect(rewindPoints.fileChangeTrackingEnabled).toBe(true); - expect(rewindPoints.points).toHaveLength(1); - const rewindPoint = rewindPoints.points[0]; - expect(rewindPoint.canRestoreFiles).toBe(true); - expect(rewindPoint.fileCount).toBe(1); + expect(rewindPoints.unavailableReason).toBeUndefined(); + expect(rewindPoints.fileChangeTrackingEnabled).toBe(true); + expect(rewindPoints.points).toHaveLength(2); + const rewindPoint = rewindPoints.points[1]; + expect(rewindPoint.turnChangedFiles).toBe(true); + expect(rewindPoint.canRestoreFiles).toBe(true); + expect(rewindPoint.fileCount).toBe(1); - const preview = await session.rpc.history.previewRewind({ - eventId: rewindPoint.eventId, - }); - expect(preview.available).toBe(true); - expect(preview.files).toHaveLength(1); - expectSamePath(preview.files[0].path, filePath); + const preview = await session.rpc.history.previewRewind({ + eventId: rewindPoint.eventId, + }); + expect(preview.available).toBe(true); + expect(preview.files).toHaveLength(1); + expectSamePath(preview.files[0].path, filePath); - const rewind = await session.rpc.history.rewind({ - eventId: rewindPoint.eventId, - mode: "conversation-and-files", - }); - expect(rewind.outcome).toBe("success"); - expect(rewind.eventsRemoved).toBeGreaterThan(0); - expect(rewind.restoredFiles).toHaveLength(1); - expectSamePath(rewind.restoredFiles[0], filePath); - expect(existsSync(filePath)).toBe(false); + const rewind = await session.rpc.history.rewind({ + eventId: rewindPoint.eventId, + mode: "conversation-and-files", + }); + expect(rewind.outcome).toBe("success"); + expect(rewind.eventsRemoved).toBeGreaterThan(0); + expect(rewind.restoredFiles).toHaveLength(1); + expectSamePath(rewind.restoredFiles[0], filePath); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, "utf8")).toBe(PREPARED_FILE_CONTENT); - const events = await session.getEvents(); - expect(events.some((event) => event.id === rewindPoint.eventId)).toBe(false); - } finally { - await session.disconnect(); - } + const events = await session.getEvents(); + expect(events.some((event) => event.id === rewindPoint.eventId)).toBe(false); + } finally { + await session.disconnect(); } - ); + }); }); diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index ad2adab109..6366db36cb 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -5,7 +5,11 @@ import { afterAll, describe, expect, it } from "vitest"; import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; -import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; +import { + createSdkTestContext, + getLegacyCliPathForTests, + isInProcessTransport, +} from "./harness/sdkTestContext.js"; describe("UI Elicitation", async () => { const { copilotClient: client } = await createSdkTestContext(); @@ -43,22 +47,28 @@ describe("UI Elicitation Callback", async () => { "session created with the elicitation ask-user variant exposes the structured tool", { timeout: 60_000 }, async () => { - const session = await client.createSession({ - onPermissionRequest: approveAll, - askUserVariant: "elicitation", - onElicitationRequest: async () => ({ action: "accept", content: {} }), + const legacyClient = ctx.createClient({ + connection: RuntimeConnection.forStdio({ path: getLegacyCliPathForTests() }), }); + try { + const session = await legacyClient.createSession({ + onPermissionRequest: approveAll, + askUserVariant: "elicitation", + onElicitationRequest: async () => ({ action: "accept", content: {} }), + }); - await session.rpc.tools.initializeAndValidate(); - const { tools } = await session.rpc.tools.getCurrentMetadata(); - const askUserSchema = tools?.find((tool) => tool.name === "ask_user")?.input_schema as - | { properties?: Record } - | undefined; - - expect(askUserSchema?.properties).toHaveProperty("message"); - expect(askUserSchema?.properties).toHaveProperty("requestedSchema"); - expect(askUserSchema?.properties).not.toHaveProperty("question"); - await session.disconnect(); + await session.rpc.tools.initializeAndValidate(); + const { tools } = await session.rpc.tools.getCurrentMetadata(); + const askUserSchema = tools?.find((tool) => tool.name === "ask_user") + ?.input_schema as { properties?: Record } | undefined; + + expect(askUserSchema?.properties).toHaveProperty("message"); + expect(askUserSchema?.properties).toHaveProperty("requestedSchema"); + expect(askUserSchema?.properties).not.toHaveProperty("question"); + await session.disconnect(); + } finally { + await legacyClient.stop(); + } } ); diff --git a/nodejs/test/runtimeArtifacts.test.ts b/nodejs/test/runtimeArtifacts.test.ts new file mode 100644 index 0000000000..4a58789e6e --- /dev/null +++ b/nodejs/test/runtimeArtifacts.test.ts @@ -0,0 +1,109 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { defaultRuntimeCacheRoot, materializeRuntimeBundle } from "../src/runtimeArtifacts.js"; + +describe("defaultRuntimeCacheRoot", () => { + it.each([ + [ + "darwin", + "/home/test", + {}, + join("/home/test", "Library", "Caches", "github-copilot-sdk", "runtime"), + ], + ["linux", "/home/test", {}, join("/home/test", ".cache", "github-copilot-sdk", "runtime")], + [ + "linux", + "/home/test", + { XDG_CACHE_HOME: "/cache" }, + join("/cache", "github-copilot-sdk", "runtime"), + ], + [ + "win32", + "C:\\Users\\test", + { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" }, + join("C:\\Users\\test\\AppData\\Local", "github-copilot-sdk", "runtime"), + ], + ])("uses the %s user cache directory", (platform, home, environment, expected) => { + expect(defaultRuntimeCacheRoot(platform, home, environment)).toBe(expected); + }); +}); + +describe("materializeRuntimeBundle", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("materializes an adjacent pair from an absent cache with a stripped environment", () => { + const sourceDir = mkdtempSync(join(tmpdir(), "copilot-runtime-source-")); + const cacheRoot = join(sourceDir, "absent-cache"); + const emptyPath = join(sourceDir, "empty-path"); + mkdirSync(emptyPath); + const wrapperName = + process.platform === "win32" ? "copilot-runtime.exe" : "copilot-runtime"; + const prebuilds = join(sourceDir, "prebuilds", "test-platform"); + const wrapper = join(prebuilds, wrapperName); + const runtimeNode = join(prebuilds, "runtime.node"); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync(wrapper, "wrapper"); + writeFileSync(runtimeNode, "runtime"); + mkdirSync(join(sourceDir, "ripgrep", "bin", "test-platform"), { recursive: true }); + writeFileSync(join(sourceDir, "ripgrep", "bin", "test-platform", "rg"), "ripgrep"); + mkdirSync(join(sourceDir, "definitions"), { recursive: true }); + writeFileSync(join(sourceDir, "definitions", "future.json"), "{}"); + writeFileSync(join(sourceDir, "app.js"), "excluded"); + writeFileSync(join(sourceDir, "copilot"), "excluded"); + writeFileSync(join(sourceDir, "copilot.exe"), "excluded"); + writeFileSync(join(sourceDir, "LICENSE.md"), "excluded"); + writeFileSync(join(sourceDir, "README.md"), "excluded"); + + vi.stubEnv("PATH", emptyPath); + vi.stubEnv("COPILOT_CLI_PATH", undefined); + vi.stubEnv("COPILOT_RUNTIME_HOST_COMMAND", undefined); + vi.stubEnv("COPILOT_RUNTIME_PROVIDER_LIB", undefined); + + expect(process.env.COPILOT_CLI_PATH).toBeUndefined(); + expect(process.env.COPILOT_RUNTIME_HOST_COMMAND).toBeUndefined(); + expect(process.env.COPILOT_RUNTIME_PROVIDER_LIB).toBeUndefined(); + + const installedWrapper = materializeRuntimeBundle( + { packageRoot: sourceDir, platform: "test-platform" }, + cacheRoot + ); + const installDir = dirname(installedWrapper); + + expect(readFileSync(installedWrapper, "utf8")).toBe("wrapper"); + expect(readFileSync(join(installDir, "runtime.node"), "utf8")).toBe("runtime"); + expect( + readFileSync(join(installDir, "ripgrep", "bin", "test-platform", "rg"), "utf8") + ).toBe("ripgrep"); + expect(existsSync(join(installDir, "app.js"))).toBe(false); + expect(existsSync(join(installDir, "copilot"))).toBe(false); + expect(existsSync(join(installDir, "copilot.exe"))).toBe(false); + expect(existsSync(join(installDir, "LICENSE.md"))).toBe(false); + expect(existsSync(join(installDir, "README.md"))).toBe(false); + if (process.platform !== "win32") { + expect(statSync(installedWrapper).mode & 0o111).not.toBe(0); + } + }); + + it("fails clearly when the package has no runtime.node", () => { + const sourceDir = mkdtempSync(join(tmpdir(), "copilot-runtime-missing-node-")); + const wrapperName = + process.platform === "win32" ? "copilot-runtime.exe" : "copilot-runtime"; + const prebuilds = join(sourceDir, "prebuilds", "test-platform"); + const wrapper = join(prebuilds, wrapperName); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync(wrapper, "wrapper"); + + expect(() => + materializeRuntimeBundle( + { + packageRoot: sourceDir, + platform: "test-platform", + }, + join(sourceDir, "cache") + ) + ).toThrow(/Copilot runtime\.node not found/); + }); +}); diff --git a/python/README.md b/python/README.md index c130f21c65..5cb1dc03c9 100644 --- a/python/README.md +++ b/python/README.md @@ -29,8 +29,9 @@ runtime: python -m copilot download-runtime ``` -This caches the runtime binary locally. If you skip this step, the SDK will -attempt to download it automatically on first use as a fallback. +This caches `copilot-runtime`, its adjacent `runtime.node`, and the compatible +`copilot` host locally. If you skip this step, the SDK downloads the bundle +automatically on first managed stdio/TCP use. To pre-provision the native library required by the in-process (FFI) transport (see [In-process (FFI) transport](#in-process-ffi-transport)), pass `--in-process`: @@ -39,15 +40,15 @@ To pre-provision the native library required by the in-process (FFI) transport python -m copilot download-runtime --in-process ``` -This additionally fetches the native runtime library into the versioned runtime -cache. Stdio/TCP users never download it. When omitted, it is downloaded -lazily on first use of the in-process transport. +This instead provisions the compatible CLI artifact and native runtime library +used by in-process hosting. When omitted, they are downloaded lazily on first +use of the in-process transport. | Platform | Cache path | |----------|-----------| -| Linux | `~/.cache/github-copilot-sdk/cli//copilot` | -| macOS | `~/Library/Caches/github-copilot-sdk/cli//copilot` | -| Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\copilot.exe` | +| Linux | `~/.cache/github-copilot-sdk/cli//prebuilds//` | +| macOS | `~/Library/Caches/github-copilot-sdk/cli//prebuilds//` | +| Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\prebuilds\\` | ### Environment variables @@ -56,7 +57,8 @@ lazily on first use of the in-process transport. | `COPILOT_CLI_PATH` | Use this specific binary instead of downloading | | `COPILOT_CLI_EXTRACT_DIR` | Override the cache directory (binary placed directly here) | | `COPILOT_SKIP_CLI_DOWNLOAD` | Set to `1` to disable auto-download | -| `COPILOT_CLI_DOWNLOAD_BASE_URL` | Override the GitHub Releases download URL | +| `COPILOT_NPM_REGISTRY_URL` | Override the npm registry used for managed out-of-process and in-process runtime downloads | +| `COPILOT_CLI_DOWNLOAD_BASE_URL` | Override the GitHub Releases download URL used for the root CLI | ## Run the Sample @@ -223,6 +225,10 @@ All options are kw-only parameters: - `RuntimeConnection.for_uri(url, connection_token=None)` — connect to an existing CLI server (e.g. `"localhost:8080"`). - `RuntimeConnection.for_inprocess()` — host the runtime in-process via its native C ABI (FFI). See [In-process (FFI) transport](#in-process-ffi-transport). +Managed stdio and TCP connections use the downloaded `copilot-runtime` +executable with adjacent `runtime.node` by default. An explicit connection +path or `COPILOT_CLI_PATH` overrides the downloaded runtime. + Child-process connections (`for_stdio`/`for_tcp`) also expose a per-connection `env` field for the spawned process. Set it on the returned connection instead of the client-level `env` — setting both raises: diff --git a/python/copilot/_cli_download.py b/python/copilot/_cli_download.py index b831e072ad..4477fcfff3 100644 --- a/python/copilot/_cli_download.py +++ b/python/copilot/_cli_download.py @@ -27,7 +27,7 @@ import tempfile import time import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath from urllib.error import HTTPError, URLError from urllib.request import urlopen @@ -373,6 +373,164 @@ def _extract_runtime_node(data: bytes, npm_platform: str) -> bytes: raise RuntimeError(f"'{target}' not found in runtime package for {npm_platform}.") +def _extract_runtime_wrapper(data: bytes, npm_platform: str) -> bytes: + """Extract the SDK out-of-process wrapper from an npm platform tarball.""" + wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime" + target = f"package/prebuilds/{npm_platform}/{wrapper_name}" + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: + for name in tf.getnames(): + if name == target or name.endswith(f"/prebuilds/{npm_platform}/{wrapper_name}"): + member = tf.getmember(name) + extracted = tf.extractfile(member) + if extracted is not None: + return extracted.read() + raise RuntimeError(f"'{target}' not found in runtime package for {npm_platform}.") + + +_HOSTLESS_EXCLUDED_TOP_LEVEL = { + "app.js", + "assets", + "changelog.json", + "copilot", + "copilot.exe", + "copilot-sdk", + "foundry-local-sdk", + "index.js", + "LICENSE.md", + "napi-oop-runtime", + "npm-loader.js", + "package.json", + "preloads", + "pvrecorder", + "queries", + "README.md", + "sdk", + "sea-loader.js", + "webview", +} + + +def _hostless_runtime_path(member_name: str, npm_platform: str) -> Path | None: + parts = PurePosixPath(member_name).parts + if not parts or parts[0] != "package" or len(parts) < 2: + return None + relative = parts[1:] + top_level = relative[0] + file_name = relative[-1] + if ( + top_level in _HOSTLESS_EXCLUDED_TOP_LEVEL + or (top_level.startswith("tree-sitter") and top_level.endswith(".wasm")) + or (top_level.startswith("voice-") and top_level.endswith(".js")) + or file_name == "cli-native.node" + or "mediaremote-adapter" in relative + or file_name.startswith("copilot-runtime-bin") + ): + return None + if top_level == "prebuilds": + if len(relative) < 3 or relative[1] != npm_platform: + return None + relative = relative[2:] + destination = Path(*relative) + if destination.is_absolute() or ".." in destination.parts: + raise RuntimeError(f"Unsafe runtime package path: {member_name}") + return destination + + +def _extract_runtime_bundle(data: bytes, npm_platform: str, destination: Path) -> None: + """Extract the hostless runtime tree, retaining unknown package assets by default.""" + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: + for member in archive: + relative = _hostless_runtime_path(member.name, npm_platform) + if relative is None or member.isdir(): + continue + if not member.isfile(): + raise RuntimeError(f"Unsupported runtime package entry: {member.name}") + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError(f"Failed to read runtime package entry: {member.name}") + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(extracted.read()) + if sys.platform != "win32": + target.chmod(member.mode & 0o777) + + +def ensure_runtime_wrapper(version: str | None = None, force: bool = False) -> str: + """Provision the runtime pair and its retained npm package assets.""" + ver = version or CLI_VERSION + if not ver: + raise RuntimeError("No runtime version is pinned.") + npm_platform = get_npm_platform() + wrapper_name = "copilot-runtime.exe" if sys.platform == "win32" else "copilot-runtime" + pair_dir = get_cache_dir(ver) / "prebuilds" / npm_platform + wrapper_path = pair_dir / wrapper_name + runtime_path = pair_dir / "runtime.node" + assets_marker = pair_dir / ".hostless-runtime-assets-v2" + + wrapper_exists = wrapper_path.is_file() and wrapper_path.stat().st_size > 0 + runtime_exists = runtime_path.is_file() and runtime_path.stat().st_size > 0 + if wrapper_exists and runtime_exists and assets_marker.is_file() and not force: + return str(wrapper_path) + if not force and wrapper_exists != runtime_exists: + raise RuntimeError( + f"Incomplete Copilot runtime bundle in {pair_dir}: " + f"{wrapper_name} and runtime.node are required." + ) + if _should_skip_download(): + raise RuntimeError( + f"Copilot runtime bundle is not cached in {pair_dir} " + "and automatic downloads are disabled." + ) + + data = _fetch_url_bytes(get_runtime_lib_url(ver, npm_platform), timeout=600) + integrity = _fetch_runtime_integrity(npm_platform, ver) + if not integrity: + raise RuntimeError( + "No Subresource Integrity value available for the Copilot runtime " + f"package ({npm_platform}@{ver}); refusing to stage unverified native code." + ) + _verify_integrity(data, integrity) + import shutil + + pair_dir.parent.mkdir(parents=True, exist_ok=True) + staging_dir = Path(tempfile.mkdtemp(dir=pair_dir.parent, prefix=".runtime-bundle-")) + try: + _extract_runtime_bundle(data, npm_platform, staging_dir) + staged_wrapper = staging_dir / wrapper_name + staged_runtime = staging_dir / "runtime.node" + if ( + not staged_wrapper.is_file() + or staged_wrapper.stat().st_size == 0 + or not staged_runtime.is_file() + or staged_runtime.stat().st_size == 0 + ): + raise RuntimeError("Copilot runtime wrapper and runtime.node must both be non-empty.") + if sys.platform != "win32": + staged_wrapper.chmod( + staged_wrapper.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + (staging_dir / assets_marker.name).write_text("1\n", encoding="ascii") + try: + if pair_dir.exists() and (force or not assets_marker.is_file()): + shutil.rmtree(pair_dir, ignore_errors=True) + staging_dir.replace(pair_dir) + except OSError: + if ( + wrapper_path.is_file() + and wrapper_path.stat().st_size > 0 + and runtime_path.is_file() + and runtime_path.stat().st_size > 0 + and assets_marker.is_file() + ): + return str(wrapper_path) + raise + finally: + if staging_dir.exists(): + shutil.rmtree(staging_dir, ignore_errors=True) + + return str(wrapper_path) + + def ensure_runtime_library(cli_path: str, version: str | None = None) -> str | None: """Ensure the native in-process (FFI) runtime library sits next to ``cli_path``. @@ -536,7 +694,10 @@ def main() -> None: print(f"Downloading Copilot runtime v{ver}...") try: - path = download_cli(ver, force=args.force) + if args.in_process: + path = download_cli(ver, force=args.force) + else: + path = ensure_runtime_wrapper(ver, force=args.force) print(f"Runtime cached at: {path}") if args.in_process: print("Downloading in-process (FFI) runtime library...") diff --git a/python/copilot/_ffi_runtime_host.py b/python/copilot/_ffi_runtime_host.py index e04d1655e6..98aa776600 100644 --- a/python/copilot/_ffi_runtime_host.py +++ b/python/copilot/_ffi_runtime_host.py @@ -3,9 +3,9 @@ Instead of spawning the Copilot CLI as a child process and talking JSON-RPC over stdio/TCP, the in-process transport loads the runtime's native shared library (``runtime.node`` — a Rust ``cdylib``) into this process and drives JSON-RPC over -its C ABI (FFI). The native ``host_start`` export spawns the residual worker -itself, so the SDK never launches the worker directly; it only pumps opaque LSP -``Content-Length:``-framed JSON-RPC bytes across the boundary: +its C ABI (FFI). The native ``host_start`` export constructs the Rust server +synchronously; the SDK only pumps opaque LSP ``Content-Length:``-framed JSON-RPC +bytes across the boundary: - client → server frames go to ``copilot_runtime_connection_write`` - server → client frames arrive on a native callback that feeds a thread-safe @@ -114,8 +114,8 @@ def _natural_library_name() -> str: return "libcopilot_runtime.so" -def resolve_library_path(cli_entrypoint: str) -> str | None: - """Resolve the native runtime library next to the given CLI entrypoint. +def resolve_library_path(runtime_entrypoint: str) -> str | None: + """Resolve the native runtime library next to the given runtime entrypoint. Checks, in order: @@ -125,7 +125,7 @@ def resolve_library_path(cli_entrypoint: str) -> str | None: Returns the absolute path, or ``None`` when neither exists. """ - directory = Path(cli_entrypoint).resolve().parent + directory = Path(runtime_entrypoint).resolve().parent flat = directory / _natural_library_name() if flat.is_file(): @@ -327,15 +327,15 @@ def wait(self, timeout: float | None = None) -> int: # noqa: ARG002 class FfiRuntimeHost: """Hosts the Copilot runtime in-process via its native C ABI. - Construct with :meth:`create`, then :meth:`start` to spawn the worker and open - the FFI connection. Expose :attr:`process` to :class:`JsonRpcClient`, and call - :meth:`dispose` to tear everything down. + Construct with :meth:`create`, then :meth:`start` to start the native engine + and open the FFI connection. Expose :attr:`process` to + :class:`JsonRpcClient`, and call :meth:`dispose` to tear everything down. """ def __init__( self, library_path: str, - cli_entrypoint: str, + cli_entrypoint: str | None, environment: dict[str, str] | None = None, args: Sequence[str] = (), ) -> None: @@ -367,31 +367,30 @@ def process(self) -> _FfiProcessAdapter: @staticmethod def create( - cli_entrypoint: str, + library_path: str, + cli_entrypoint: str | None = None, environment: dict[str, str] | None = None, args: Sequence[str] = (), ) -> FfiRuntimeHost: - """Resolve the cdylib next to the CLI entrypoint and prepare the host. + """Load the runtime cdylib and prepare the host. Raises: RuntimeError: If the native runtime library cannot be found. """ - full_entrypoint = str(Path(cli_entrypoint).resolve()) - library_path = resolve_library_path(full_entrypoint) - if library_path is None: + full_library_path = str(Path(library_path).resolve()) + if not Path(full_library_path).is_file(): raise RuntimeError( - "In-process FFI runtime library not found next to " - f"'{full_entrypoint}'. Download it with " - "`python -m copilot download-runtime --in-process`, or set " - "COPILOT_CLI_PATH to a runtime package that ships it." + f"In-process FFI runtime library not found at '{full_library_path}'." ) - return FfiRuntimeHost(library_path, full_entrypoint, environment, args) + full_entrypoint = ( + str(Path(cli_entrypoint).resolve()) if cli_entrypoint is not None else None + ) + return FfiRuntimeHost(full_library_path, full_entrypoint, environment, args) def _build_argv(self) -> bytes: - # A `.js` entrypoint (dev) is launched via node; the packaged single-file - # CLI embeds its own Node and is invoked directly. `--no-auto-update` - # pins the worker to the runtime package matching the loaded cdylib. - if self._cli_entrypoint.lower().endswith(".js"): + if self._cli_entrypoint is None: + argv: list[str] = [] + elif self._cli_entrypoint.lower().endswith(".js"): argv = ["node", self._cli_entrypoint, "--embedded-host", "--no-auto-update"] else: argv = [self._cli_entrypoint, "--embedded-host", "--no-auto-update"] @@ -407,11 +406,9 @@ def _build_env(self) -> bytes | None: return json.dumps(obj).encode("utf-8") def start_blocking(self) -> None: - """Spawn the worker and open the FFI connection (blocks up to ~30s). + """Start the native engine and open the FFI connection. - Must be run off the event loop (e.g. via :func:`asyncio.to_thread`); - ``host_start`` blocks until the worker connects back and signals - readiness. + Must be run off the event loop (e.g. via :func:`asyncio.to_thread`). """ argv = self._build_argv() env = self._build_env() @@ -419,8 +416,7 @@ def start_blocking(self) -> None: self._server_id = self._lib.host_start(argv, len(argv), env, len(env) if env else 0) if not self._server_id: raise RuntimeError( - f"copilot_runtime_host_start failed (library '{self._library_path}', " - f"entrypoint '{self._cli_entrypoint}')." + f"copilot_runtime_host_start failed (library '{self._library_path}')." ) self._outbound_callback = _OutboundCallback(self._on_outbound) diff --git a/python/copilot/client.py b/python/copilot/client.py index 05412246de..1c0691b612 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -28,6 +28,7 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from datetime import UTC, datetime +from pathlib import Path from types import TracebackType from typing import Any, ClassVar, Literal, NotRequired, TypedDict, cast, overload @@ -1356,25 +1357,6 @@ def _session_lifecycle_event_from_dict(data: dict) -> SessionLifecycleEvent: _CLI_PROCESS_EXIT_TIMEOUT_SECONDS = 5 -def _get_or_download_cli(*, include_runtime_lib: bool = False) -> str | None: - """Get the cached CLI binary, downloading if necessary. - - Returns the path to the CLI binary, or None if unavailable (dev install - with no pinned version, or auto-download disabled). - - When ``include_runtime_lib`` is set, also ensures the native in-process FFI - runtime is available (downloading it on first use). - """ - from ._cli_download import get_or_download_cli - - cli_path = get_or_download_cli() - if cli_path and include_runtime_lib: - from ._cli_download import ensure_runtime_library - - ensure_runtime_library(cli_path) - return cli_path - - def _extract_transform_callbacks( system_message: SystemMessageConfig | dict[str, Any] | None, ) -> tuple[dict[str, Any] | None, dict[str, SectionTransformFn] | None]: @@ -1651,6 +1633,7 @@ def __init__( self._cli_path_source: str | None = None self._ffi_host: FfiRuntimeHost | None = None self._inprocess_runtime_path: str | None = None + self._inprocess_cli_entrypoint: str | None = None if isinstance(connection, UriRuntimeConnection): if connection.connection_token is not None and len(connection.connection_token) == 0: @@ -1662,9 +1645,7 @@ def __init__( # In-process (FFI): no child process and no per-connection token. self._runtime_port = None self._effective_connection_token = None - self._inprocess_runtime_path = self._resolve_runtime_entrypoint( - None, include_runtime_lib=True - ) + self._inprocess_runtime_path = self._resolve_inprocess_runtime() if options.use_logged_in_user is None: options.use_logged_in_user = not bool(options.github_token) else: @@ -1685,7 +1666,7 @@ def __init__( else: self._effective_connection_token = None - # Resolve CLI path: explicit > COPILOT_CLI_PATH env var > downloaded binary. + # Resolve runtime path: explicit CLI > COPILOT_CLI_PATH > downloaded runtime. # Select the environment by identity, not truthiness, so an intentionally # empty per-connection or client env stays authoritative (the spawned child # receives that empty mapping) instead of falling back to os.environ and @@ -1730,52 +1711,47 @@ def _resolve_runtime_entrypoint( path: str | None, *, env: Mapping[str, str] | None = None, - include_runtime_lib: bool = False, ) -> str: """Resolve the runtime executable path (explicit > env > downloaded). Sets ``self._cli_path_source`` for diagnostics. When - ``include_runtime_lib`` is set (in-process transport), also ensures the - native runtime library is downloaded alongside the CLI. - Raises: RuntimeError: If no runtime path can be resolved. """ if path is not None: self._cli_path_source = "explicit" - return self._ensure_runtime_lib(path) if include_runtime_lib else path + return path lookup = env if env is not None else os.environ env_cli_path = lookup.get("COPILOT_CLI_PATH") if env_cli_path: self._cli_path_source = "environment" - return self._ensure_runtime_lib(env_cli_path) if include_runtime_lib else env_cli_path - - downloaded_path = _get_or_download_cli(include_runtime_lib=include_runtime_lib) - if downloaded_path: - self._cli_path_source = "downloaded" - return downloaded_path - - raise RuntimeError( - "Copilot CLI not found. Install a published wheel (which " - "auto-downloads the CLI on first use), set COPILOT_CLI_PATH, " - "or pass an explicit path via " - "RuntimeConnection.for_stdio(path=...) / " - "RuntimeConnection.for_tcp(path=...)." - ) + return env_cli_path - @staticmethod - def _ensure_runtime_lib(cli_path: str) -> str: - """Ensure the in-process runtime library sits next to a user-supplied CLI. + from ._cli_download import ensure_runtime_wrapper - For explicit/``COPILOT_CLI_PATH`` entrypoints, the native library may - already be bundled (dev ``prebuilds`` layout); otherwise it is fetched on - first use. Returns ``cli_path`` unchanged. - """ - from ._cli_download import ensure_runtime_library + self._cli_path_source = "downloaded" + return ensure_runtime_wrapper() - ensure_runtime_library(cli_path) - return cli_path + def _resolve_inprocess_runtime(self) -> str: + explicit_cli = os.environ.get("COPILOT_CLI_PATH") + if explicit_cli: + from ._cli_download import ensure_runtime_library + + runtime_path = ensure_runtime_library(explicit_cli) + if runtime_path is None: + raise RuntimeError( + f"In-process runtime library not found next to '{explicit_cli}'." + ) + self._cli_path_source = "environment" + self._inprocess_cli_entrypoint = explicit_cli + return runtime_path + + from ._cli_download import ensure_runtime_wrapper + + wrapper_path = Path(ensure_runtime_wrapper()) + self._cli_path_source = "downloaded" + return str(wrapper_path.with_name("runtime.node")) @property def rpc(self) -> ServerRpc: @@ -4306,7 +4282,6 @@ async def _start_cli_server(self) -> None: env = dict(os.environ) else: env = dict(opts.env) - # Set auth token in environment if provided if opts.github_token: env["COPILOT_SDK_AUTH_TOKEN"] = opts.github_token @@ -4457,6 +4432,7 @@ async def _start_inprocess_ffi(self) -> None: host = FfiRuntimeHost.create( runtime_path, + cli_entrypoint=self._inprocess_cli_entrypoint, environment=environment or None, args=tuple(args), ) diff --git a/python/e2e/conftest.py b/python/e2e/conftest.py index f441097f32..a789b2b567 100644 --- a/python/e2e/conftest.py +++ b/python/e2e/conftest.py @@ -1,10 +1,14 @@ """Shared pytest fixtures for e2e tests.""" +import json import os +from pathlib import Path import pytest import pytest_asyncio +import copilot._cli_download as cli_download + from .testharness import E2ETestContext, is_inprocess_transport # Host-side auth resolution ranks HMAC above the GitHub token, so an ambient @@ -15,9 +19,16 @@ # .NET's InProcessEnvIsolation [ModuleInitializer] and Node's module-init guard. # Out-of-process children resolve auth in their own process where the token already # outranks HMAC. See https://github.com/github/copilot-sdk/issues/1934. +if not cli_download.CLI_VERSION: + package_lock = json.loads( + (Path(__file__).parents[2] / "nodejs" / "package-lock.json").read_text() + ) + cli_download.CLI_VERSION = package_lock["packages"]["node_modules/@github/copilot"]["version"] + if is_inprocess_transport(): os.environ.pop("COPILOT_HMAC_KEY", None) os.environ.pop("CAPI_HMAC_KEY", None) + os.environ.pop("COPILOT_CLI_PATH", None) @pytest.hookimpl(tryfirst=True, hookwrapper=True) diff --git a/python/e2e/test_inprocess_ffi_e2e.py b/python/e2e/test_inprocess_ffi_e2e.py index c119c4ea4e..ea82037b7a 100644 --- a/python/e2e/test_inprocess_ffi_e2e.py +++ b/python/e2e/test_inprocess_ffi_e2e.py @@ -15,20 +15,14 @@ from copilot import CopilotClient, RuntimeConnection from .testharness import E2ETestContext -from .testharness.context import get_cli_path_for_tests pytestmark = pytest.mark.asyncio(loop_scope="module") class TestInProcessFfi: - async def test_should_start_and_connect_over_in_process_ffi( - self, ctx: E2ETestContext, monkeypatch: pytest.MonkeyPatch - ): - # In-process hosting loads the runtime cdylib next to the resolved CLI - # entrypoint and lets the native host spawn the worker. ``ping`` is a - # purely local RPC round-trip, so no auth or replay proxy is involved. - # If the native library is unavailable, start() raises and the test fails. - monkeypatch.setenv("COPILOT_CLI_PATH", get_cli_path_for_tests()) + async def test_should_start_and_connect_over_in_process_ffi(self, ctx: E2ETestContext): + # In-process hosting loads runtime.node directly. ``ping`` is a purely local + # RPC round-trip, so no auth or replay proxy is involved. client = CopilotClient(connection=RuntimeConnection.for_inprocess()) await client.start() diff --git a/python/e2e/test_mode_handlers_e2e.py b/python/e2e/test_mode_handlers_e2e.py index f6173a4a5e..d5182c453d 100644 --- a/python/e2e/test_mode_handlers_e2e.py +++ b/python/e2e/test_mode_handlers_e2e.py @@ -6,6 +6,7 @@ import pytest +from copilot.rpc import ModeSetRequest from copilot.session import PermissionHandler from copilot.session_events import ( AutoModeSwitchCompletedData, @@ -15,6 +16,7 @@ ExitPlanModeCompletedData, ExitPlanModeRequestedData, SessionIdleData, + SessionMode, SessionModelChangeData, ) @@ -111,10 +113,8 @@ async def on_exit_plan_mode_request(request, invocation): ) ) - response = await session.send_and_wait( - PLAN_PROMPT, - agent_mode="plan", - ) + await session.rpc.mode.set(ModeSetRequest(mode=SessionMode.PLAN)) + response = await session.send_and_wait(PLAN_PROMPT) assert len(exit_plan_mode_requests) == 1 request = exit_plan_mode_requests[0] diff --git a/python/e2e/test_rewind_e2e.py b/python/e2e/test_rewind_e2e.py index 10e7bc4dd9..fa87c08b64 100644 --- a/python/e2e/test_rewind_e2e.py +++ b/python/e2e/test_rewind_e2e.py @@ -4,7 +4,6 @@ import asyncio import os -import sys from pathlib import Path import pytest @@ -22,6 +21,8 @@ pytestmark = pytest.mark.asyncio(loop_scope="module") FILE_NAME = "rewind-sdk.txt" +ORIGINAL_FILE_CONTENT = "Original rewind content" +PREPARED_FILE_CONTENT = "Prepared rewind content" FILE_CONTENT = "SDK rewind content" @@ -31,10 +32,8 @@ def _same_path(left: str | Path, right: str | Path) -> bool: class TestRewind: async def test_should_restore_tracked_file_and_conversation(self, ctx: E2ETestContext): - if sys.platform == "win32": - pytest.skip("blocked on CLI 1.0.81 file-change tracking regression on Windows") - file_path = Path(ctx.work_dir) / FILE_NAME + file_path.write_text(ORIGINAL_FILE_CONTENT, encoding="utf-8") session = await ctx.client.create_session( model="claude-sonnet-4.5", enable_file_change_tracking=True, @@ -42,8 +41,18 @@ async def test_should_restore_tracked_file_and_conversation(self, ctx: E2ETestCo ) try: + ready = await session.send_and_wait( + f"Use the edit tool to replace the exact contents of {FILE_NAME} " + f"from {ORIGINAL_FILE_CONTENT} to {PREPARED_FILE_CONTENT}. " + "After the tool succeeds, reply with exactly SDK_REWIND_READY." + ) + assert ready is not None + assert ready.data.content == "SDK_REWIND_READY" + assert file_path.read_text(encoding="utf-8") == PREPARED_FILE_CONTENT + response = await session.send_and_wait( - f"Use the create tool to create {FILE_NAME} containing exactly {FILE_CONTENT}. " + f"Use the edit tool to replace the exact contents of {FILE_NAME} " + f"from {PREPARED_FILE_CONTENT} to {FILE_CONTENT}. " "After the tool succeeds, reply with exactly SDK_REWIND_DONE." ) @@ -59,16 +68,18 @@ async def test_should_restore_tracked_file_and_conversation(self, ctx: E2ETestCo deadline = asyncio.get_running_loop().time() + 30 while asyncio.get_running_loop().time() < deadline and not ( rewind_points.unavailable_reason is None - and rewind_points.points - and rewind_points.points[0].can_restore_files + and len(rewind_points.points) == 2 + and rewind_points.points[1].turn_changed_files + and rewind_points.points[1].can_restore_files ): await asyncio.sleep(0.1) rewind_points = await session.rpc.history.list_rewind_points() assert rewind_points.unavailable_reason is None assert rewind_points.file_change_tracking_enabled - assert len(rewind_points.points) == 1 - rewind_point = rewind_points.points[0] + assert len(rewind_points.points) == 2 + rewind_point = rewind_points.points[1] + assert rewind_point.turn_changed_files assert rewind_point.can_restore_files assert rewind_point.file_count == 1 @@ -89,7 +100,7 @@ async def test_should_restore_tracked_file_and_conversation(self, ctx: E2ETestCo assert rewind.events_removed is not None and rewind.events_removed > 0 assert len(rewind.restored_files) == 1 assert _same_path(rewind.restored_files[0], file_path) - assert not file_path.exists() + assert file_path.read_text(encoding="utf-8") == PREPARED_FILE_CONTENT events = await session.get_events() assert all(str(event.id) != rewind_point.event_id for event in events) diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 2171e25f2d..8eaecf6244 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -191,7 +191,6 @@ def _apply_inprocess_environment(self) -> None: { "GH_TOKEN": DEFAULT_GITHUB_TOKEN, "GITHUB_TOKEN": DEFAULT_GITHUB_TOKEN, - "COPILOT_CLI_PATH": self.cli_path, "COPILOT_HMAC_KEY": "", "CAPI_HMAC_KEY": "", } @@ -274,11 +273,13 @@ async def configure_for_test(self, test_file: str, test_name: str): if self._proxy: await self._proxy.configure(abs_snapshot_path, self.work_dir) - # Clear temp directories between tests (but leave them in place) - # Use ignore_errors=True / suppress(OSError) to handle race conditions - # where files (e.g., SQLite session-store.db on Windows) may still be - # held open by a background process during cleanup. - for base_dir in (self.home_dir, self.work_dir): + # Keep the in-process runtime's isolated home intact until teardown stops + # the runtime. Removing its open state files on POSIX can leave later tests + # using unlinked database state. + cleanup_dirs = ( + (self.work_dir,) if self._client_inprocess else (self.home_dir, self.work_dir) + ) + for base_dir in cleanup_dirs: base_path = Path(base_dir) base_path.mkdir(parents=True, exist_ok=True) for item in base_path.iterdir(): diff --git a/python/test_cli_download.py b/python/test_cli_download.py index 36952919df..a5a20dce0d 100644 --- a/python/test_cli_download.py +++ b/python/test_cli_download.py @@ -4,6 +4,9 @@ import base64 import hashlib +import io +import os +import tarfile from unittest.mock import patch import pytest @@ -16,6 +19,28 @@ def _integrity(data: bytes, algo: str = "sha512") -> str: return f"{algo}-{base64.b64encode(digest).decode('ascii')}" +def _runtime_package(npm_platform: str) -> bytes: + wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime" + members = { + f"package/prebuilds/{npm_platform}/{wrapper_name}": b"wrapper", + f"package/prebuilds/{npm_platform}/runtime.node": b"runtime", + "package/copilot": b"excluded", + "package/copilot.exe": b"excluded", + f"package/ripgrep/bin/{npm_platform}/rg": b"ripgrep", + "package/definitions/future.json": b"{}", + "package/app.js": b"excluded", + "package/LICENSE.md": b"excluded", + "package/README.md": b"excluded", + } + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + for name, content in members.items(): + info = tarfile.TarInfo(name) + info.size = len(content) + archive.addfile(info, io.BytesIO(content)) + return buffer.getvalue() + + class TestVerifyIntegrity: def test_accepts_matching_checksum(self): data = b"native-library-bytes" @@ -51,3 +76,92 @@ def test_raises_when_integrity_unavailable(self, tmp_path): # The library bytes must never be extracted/written when verification is impossible. extract.assert_not_called() + + +class TestEnsureRuntimeWrapper: + def test_materializes_pair_from_absent_cache_with_stripped_environment( + self, tmp_path, monkeypatch + ): + npm_platform = "win32-x64" if os.name == "nt" else "linux-x64" + wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime" + data = _runtime_package(npm_platform) + cache_dir = tmp_path / "cache" + empty_path = tmp_path / "empty-path" + empty_path.mkdir() + assert not cache_dir.exists() + + for name in ( + "COPILOT_CLI_PATH", + "COPILOT_RUNTIME_HOST_COMMAND", + "COPILOT_RUNTIME_PROVIDER_LIB", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("PATH", str(empty_path)) + + with ( + patch.object(_cli_download, "get_cache_dir", return_value=cache_dir), + patch.object(_cli_download, "get_npm_platform", return_value=npm_platform), + patch.object(_cli_download, "_should_skip_download", return_value=False), + patch.object(_cli_download, "_fetch_url_bytes", return_value=data), + patch.object( + _cli_download, + "_fetch_runtime_integrity", + return_value=_integrity(data), + ), + ): + wrapper = _cli_download.ensure_runtime_wrapper(version="1.2.3") + + install_dir = cache_dir / "prebuilds" / npm_platform + assert wrapper == str(install_dir / wrapper_name) + assert (install_dir / wrapper_name).read_bytes() == b"wrapper" + assert (install_dir / "runtime.node").read_bytes() == b"runtime" + assert (install_dir / "ripgrep" / "bin" / npm_platform / "rg").read_bytes() == b"ripgrep" + assert (install_dir / "definitions" / "future.json").read_bytes() == b"{}" + assert not (install_dir / "app.js").exists() + assert not (install_dir / "copilot").exists() + assert not (install_dir / "copilot.exe").exists() + assert (install_dir / ".hostless-runtime-assets-v2").is_file() + if os.name != "nt": + assert (install_dir / wrapper_name).stat().st_mode & 0o111 + + def test_rejects_cached_wrapper_without_runtime_node(self, tmp_path): + npm_platform = "win32-x64" if os.name == "nt" else "linux-x64" + wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime" + cache_dir = tmp_path / "cache" + install_dir = cache_dir / "prebuilds" / npm_platform + install_dir.mkdir(parents=True) + (install_dir / wrapper_name).write_bytes(b"wrapper") + + with ( + patch.object(_cli_download, "get_cache_dir", return_value=cache_dir), + patch.object(_cli_download, "get_npm_platform", return_value=npm_platform), + ): + with pytest.raises(RuntimeError, match="Incomplete Copilot runtime bundle"): + _cli_download.ensure_runtime_wrapper(version="1.2.3") + + def test_upgrades_pair_only_cache_with_retained_assets(self, tmp_path): + npm_platform = "win32-x64" if os.name == "nt" else "linux-x64" + wrapper_name = "copilot-runtime.exe" if os.name == "nt" else "copilot-runtime" + cache_dir = tmp_path / "cache" + install_dir = cache_dir / "prebuilds" / npm_platform + install_dir.mkdir(parents=True) + (install_dir / wrapper_name).write_bytes(b"old-wrapper") + (install_dir / "runtime.node").write_bytes(b"old-runtime") + (install_dir / "copilot").write_bytes(b"legacy-sea") + (install_dir / ".hostless-runtime-assets-v1").write_text("1\n", encoding="ascii") + data = _runtime_package(npm_platform) + + with ( + patch.object(_cli_download, "get_cache_dir", return_value=cache_dir), + patch.object(_cli_download, "get_npm_platform", return_value=npm_platform), + patch.object(_cli_download, "_should_skip_download", return_value=False), + patch.object(_cli_download, "_fetch_url_bytes", return_value=data), + patch.object(_cli_download, "_fetch_runtime_integrity", return_value=_integrity(data)), + ): + wrapper = _cli_download.ensure_runtime_wrapper(version="1.2.3") + + assert wrapper == str(install_dir / wrapper_name) + assert (install_dir / wrapper_name).read_bytes() == b"wrapper" + assert not (install_dir / "copilot").exists() + assert (install_dir / ".hostless-runtime-assets-v2").is_file() + assert (install_dir / "ripgrep" / "bin" / npm_platform / "rg").is_file() diff --git a/python/test_client.py b/python/test_client.py index bfcee83db2..cb78db2094 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -59,6 +59,27 @@ def test_inprocess_connection_has_no_child_process_options(): assert not hasattr(connection, "args") +def test_explicit_child_process_path_does_not_require_runtime_bundle(tmp_path): + explicit = tmp_path / "copilot" + connection = RuntimeConnection.for_stdio(path=str(explicit)) + + CopilotClient(connection=connection, env={"PATH": str(tmp_path)}) + + assert connection.path == str(explicit) + + +def test_copilot_cli_path_does_not_require_runtime_bundle(tmp_path): + explicit = tmp_path / "copilot" + connection = RuntimeConnection.for_stdio() + + CopilotClient( + connection=connection, + env={"PATH": str(tmp_path), "COPILOT_CLI_PATH": str(explicit)}, + ) + + assert connection.path == str(explicit) + + class TestBuiltinPluginDirectories: @staticmethod async def _start_client(paths=None): diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0f18a9b159..a28190cd1f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -90,6 +90,21 @@ required-features = ["test-support"] name = "protocol_version_test" required-features = ["test-support"] +[[test]] +name = "extension_launch_provider_test" +required-features = ["test-support"] + +[[test]] +name = "extension_launch_provider_runtime_test" +required-features = ["test-support"] + +[[bin]] +name = "copilot-extension-test-fixture" +path = "tests/fixtures/extension_fixture.rs" +required-features = ["test-support"] +test = false +bench = false + [build-dependencies] base64 = "0.22" dirs = "5" diff --git a/rust/README.md b/rust/README.md index 6114105c25..c05746a30e 100644 --- a/rust/README.md +++ b/rust/README.md @@ -101,8 +101,63 @@ transports. | `env_remove` | `Vec` | Environment variables to remove | | `extra_args` | `Vec` | Extra CLI flags | | `transport` | `Transport` | `Default`, `Stdio`, `InProcess`, `Tcp`, or `External` | +| `extension_launch_provider` | `Option>` | Connection-global extension launch resolver | -With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in this order: an explicit `CliProgram::Path(path)`, the `COPILOT_CLI_PATH` env var, then the bundled CLI that was embedded at build time. There is no PATH scanning — if you've opted out of bundling (`default-features = false`) you must supply either `CliProgram::Path` or `COPILOT_CLI_PATH`. +With the default `CliProgram::Resolve`, managed stdio and TCP transports resolve an explicit `CliProgram::Path(path)`, `COPILOT_CLI_PATH`, then the bundled `copilot-runtime` wrapper and adjacent `runtime.node`. In-process transport retains its CLI-entrypoint resolution. There is no PATH scanning. + +#### Extension launch provider + +Hosts that own legacy extension process assets can supply a typed, asynchronous +launch resolver: + +```rust,ignore +use std::collections::HashMap; + +use async_trait::async_trait; +use github_copilot_sdk::extension_launch_provider::{ + ExtensionLaunchProfile, ExtensionLaunchProvider, ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, +}; +use github_copilot_sdk::{Client, ClientOptions, Result}; + +struct AppExtensionLaunchProvider; + +#[async_trait] +impl ExtensionLaunchProvider for AppExtensionLaunchProvider { + async fn resolve( + &self, + request: ExtensionLaunchProviderResolveRequest, + ) -> Result { + Ok(ExtensionLaunchProviderResolveResult { + launch: Some(ExtensionLaunchProfile { + executable: "/app/copilot".to_string(), + args: vec!["/app/preloads/extension_bootstrap.mjs".to_string()], + env: HashMap::from([ + ("COPILOT_AUTO_UPDATE".to_string(), "false".to_string()), + ("EXTENSION_PATH".to_string(), request.module_path), + ]), + }), + }) + } +} + +let client = Client::start( + ClientOptions::new().with_extension_launch_provider(AppExtensionLaunchProvider), +).await?; +``` + +`Client::start` registers the provider before returning, and reverse requests +are routed at the connection level rather than through a session. The SDK +forwards the returned executable, arguments, and environment unchanged; it +does not discover or bundle an executable or bootstrap. The runtime owns and +overrides `COPILOT_SDK_PATH`, `SESSION_ID`, and +`COPILOT_EXTENSION_PARENT_PID`. + +`COPILOT_CLI_DIST_DIR` is only appropriate when the host supplies a complete +CLI distribution containing `index.js` and its matching preloads. When the +executable is a version-matched standalone Copilot binary, omit that variable +and set `COPILOT_AUTO_UPDATE=false` so its embedded distribution remains +selected. ### Session @@ -826,6 +881,7 @@ none of them are scheduled for removal. | File | Description | | ----------------- | -------------------------------------------------------------------------------------------------------------------------- | | `lib.rs` | `Client`, `ClientOptions`, `CliProgram`, `Transport`, `Error` | +| `extension_launch_provider.rs` | Connection-global `ExtensionLaunchProvider` trait and launch profile DTOs | | `session.rs` | `Session` struct, event loop, `send`/`send_and_wait`, `Client::create_session`/`resume_session` | | `subscription.rs` | `EventSubscription` / `LifecycleSubscription` (`Stream`-able observer handles for `subscribe()` / `subscribe_lifecycle()`) | | `handler.rs` | `PermissionHandler`, `ElicitationHandler`, `UserInputHandler`, `ExitPlanModeHandler`, `AutoModeSwitchHandler` traits; `ApproveAllHandler`, `DenyAllHandler` | @@ -835,13 +891,15 @@ none of them are scheduled for removal. | `types.rs` | CLI protocol types (`SessionId`, `SessionEvent`, `SessionConfig`, `Tool`, etc.) | | `resolve.rs` | Bundled-CLI resolution (`copilot_binary`) | | `embeddedcli.rs` | Embedded CLI extraction (gated on the default `bundled-cli` feature) | -| `router.rs` | Internal per-session event demux | +| `router.rs` | Internal connection-global request dispatch and per-session event demux | | `jsonrpc.rs` | Internal Content-Length framed JSON-RPC transport | -## Embedded CLI +## Bundled runtime artifacts The SDK provisions its runtime at build time. By default the `bundled-cli` -feature embeds the verified child-process runtime in your compiled crate. +feature embeds the verified `copilot-runtime` wrapper and adjacent +`runtime.node` in your compiled crate. The compatible CLI artifact remains +available separately for `install_bundled_cli` and in-process hosting. Enable `bundled-in-process` to additionally embed the native runtime library and use `Transport::InProcess`: @@ -859,15 +917,11 @@ For builds that prefer a smaller artifact, disable the `bundled-cli` feature: github-copilot-sdk = { version = "0.1", default-features = false } ``` -> **You become responsible for supplying the CLI at runtime.** With -> `bundled-cli` disabled, the produced binary does not contain the CLI -> and will not search the system for one. You must point it at a -> compatible CLI via [`CliProgram::Path`] (on `ClientOptions`) or the -> `COPILOT_CLI_PATH` environment variable, and you are responsible for -> guaranteeing the supplied CLI version is compatible with this SDK -> release. Do **not** assume that whatever CLI happens to be installed -> on the target system will work — the SDK and CLI are versioned -> together. +> **You become responsible for supplying the runtime at deployment.** With +> `bundled-cli` disabled, the produced binary does not contain these artifacts +> and will not search the system for them. For managed child-process transports, +> supply a compatible wrapper pair via an explicit [`CliProgram::Path`]. +> `COPILOT_CLI_PATH` remains a direct program override. > > **Convenience on the build machine only.** As a special case, > `build.rs` downloads and integrity-verifies the compatible CLI version and @@ -876,8 +930,8 @@ github-copilot-sdk = { version = "0.1", default-features = false } > makes local development and CI ergonomic, but it does **not** carry > over when you copy the built binary to another machine — distributed > builds (release artifacts, signed installers, container images, etc.) -> must either keep `bundled-cli` enabled or ship the CLI alongside and -> set `CliProgram::Path` / `COPILOT_CLI_PATH`. +> must either keep `bundled-cli` enabled or ship the runtime pair and set +> `CliProgram::Path`. ### How it works @@ -890,17 +944,17 @@ github-copilot-sdk = { version = "0.1", default-features = false } 2. **Build time:** `build.rs` downloads the platform-specific npm package and verifies its `sha512` integrity against the lockfile or publish snapshot. Then: - - **`bundled-cli` on (default):** creates and embeds a minimal archive containing only the CLI executable. - - **`bundled-in-process` on:** the minimal archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`); no other npm package files are embedded. - - **`bundled-cli` off:** extracts the binary directly into the platform cache (staging file + atomic rename), idempotent across rebuilds. If the extracted binary is already present at the expected path, the download is skipped entirely — the extracted binary *is* the cache. + - **`bundled-cli` on (default):** creates and embeds a minimal archive containing the CLI executable, `copilot-runtime[.exe]`, and `runtime.node`. + - **`bundled-in-process` on:** the archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`). + - **`bundled-cli` off:** extracts the same artifacts directly into the platform cache using staging files and atomic renames. -3. **Runtime:** in both modes the binary lives at: +3. **Runtime:** in both modes the artifacts share one versioned directory: | OS | Path | |----|------| - | macOS | `~/Library/Caches/github-copilot-sdk/cli//copilot` | - | Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli//copilot` | - | Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\copilot.exe` | + | macOS | `~/Library/Caches/github-copilot-sdk/cli//` | + | Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli//` | + | Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\` | Old version directories accumulate in siblings; clean them up at your leisure. @@ -929,18 +983,20 @@ COPILOT_CLI_EXTRACT_DIR = { value = "vendor/copilot", relative = true, force = t ### Skipping the bundle entirely -Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the CLI at runtime via `ClientOptions::program = CliProgram::Path(...)` or `COPILOT_CLI_PATH`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless one of those explicit sources resolves. +Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the managed runtime via `ClientOptions::program = CliProgram::Path(...)`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves. ### Resolution priority -`Client::start` resolves the CLI in this order: +For managed child-process transports, `Client::start` resolves the program in this order: 1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`. 2. `COPILOT_CLI_PATH` environment variable, if it points at a real file. -3. **`bundled-cli` on:** the embedded archive, lazily extracted on first call. -4. **`bundled-cli` off:** the build-time-extracted binary in the per-user cache, located by recomputing the convention from `COPILOT_SDK_CLI_VERSION` + OS + optional `COPILOT_CLI_EXTRACT_DIR`. +3. **`bundled-cli` on:** the embedded wrapper pair, lazily extracted on first call. +4. **`bundled-cli` off:** the build-time-extracted wrapper pair in the per-user cache. -There is no PATH scanning. If none of the above resolves, `Client::start` returns `Error::BinaryNotFound`. +In-process transport resolves the compatible CLI artifact from +`COPILOT_CLI_PATH`, the embedded archive, or the build-time cache. There is no +PATH scanning. ### Reaching the bundled binary without a `Client` @@ -960,12 +1016,24 @@ if HAS_BUNDLED_CLI { } ``` -This returns the same path `Client::start` would resolve to for -`CliProgram::Resolve` with no `COPILOT_CLI_PATH` override and no -`ClientOptions::bundled_cli_extract_dir` configured. It returns `None` -when `bundled-cli` is off or the target is unsupported, and (unlike the -full resolver) does not fall back to the build-time-extracted dev-cache -path. +This returns the bundled CLI artifact, preserving the public API's original +meaning. Managed child-process transports resolve `copilot-runtime` instead. +The function returns `None` when `bundled-cli` is off or the target is +unsupported and does not fall back to the build-time extraction cache. + +Use [`install_bundled_runtime`] when a health check or intermediate launcher +needs the managed runtime executable: + +```rust,no_run +use github_copilot_sdk::install_bundled_runtime; + +if let Some(path) = install_bundled_runtime() { + println!("bundled runtime at {}", path.display()); +} +``` + +This extracts `copilot-runtime` together with adjacent `runtime.node`, then +returns the wrapper path. ### Download cache (build-time, embed mode) @@ -979,8 +1047,8 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64` | Feature | Default | Description | | ------- | ------- | ----------- | -| `bundled-cli` | ✓ | Embeds only the CLI executable. Disable via `default-features = false` when supplying the CLI via `CliProgram::Path` or `COPILOT_CLI_PATH`. | -| `bundled-in-process` | — | Enables `Transport::InProcess`, implies `bundled-cli`, and additionally embeds only the platform-native runtime library. | +| `bundled-cli` | ✓ | Embeds the managed wrapper pair and compatible CLI artifact. Disable via `default-features = false` when supplying the runtime explicitly. | +| `bundled-in-process` | — | Enables `Transport::InProcess`, implies `bundled-cli`, and additionally embeds the platform-native runtime library. | | `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). | ```toml diff --git a/rust/build.rs b/rust/build.rs index d04cf2870b..c01464bb4a 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,11 +1,6 @@ -#[cfg(feature = "bundled-in-process")] #[path = "build/in_process.rs"] mod implementation; -#[cfg(not(feature = "bundled-in-process"))] -#[path = "build/out_of_process.rs"] -mod implementation; - fn main() { implementation::main(); } diff --git a/rust/build/in_process.rs b/rust/build/in_process.rs index 5826fbfa76..f1e947b27a 100644 --- a/rust/build/in_process.rs +++ b/rust/build/in_process.rs @@ -40,7 +40,7 @@ pub(crate) fn main() { // path source resolves first. if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() { println!( - "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping CLI download/bundle/cache" + "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping runtime download/bundle/cache" ); return; } @@ -95,38 +95,61 @@ pub(crate) fn main() { if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { let archive = cached_download(&download_url, &cache_key, &expected_integrity, &cache_dir); - verify_binary_present_in_archive(&archive, platform.binary_name, &archive_name); + verify_runtime_package(&archive, platform, &archive_name); emit_embedded(out, &archive, platform, include_runtime); println!("cargo:rustc-cfg=has_bundled_cli"); } else { - // With `bundled-cli` off the extracted binary *is* the cache. - // Skip the upstream download entirely when it already exists at - // the expected path. No two separate caches. + // With `bundled-cli` off the extracted runtime pair *is* the cache. + // Skip the upstream download entirely when both files already exist. // - // Runtime resolution (see `src/resolve.rs::extracted_cli_path`) + // Runtime resolution (see `src/resolve.rs::extracted_program`) // recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the // OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`, // so we don't bake an absolute path into the crate. let install_dir = extracted_install_dir(&version); - let final_path = install_dir.join(platform.binary_name); - - // Invalidate build.rs whenever the cached binary disappears (cache GC, - // manual rm, OS reset, switching extract dir). Without this, cargo + let required_paths = [ + install_dir.join(platform.runtime_wrapper_name()), + install_dir.join("runtime.node"), + install_dir.join(".hostless-runtime-assets-v1"), + ]; + let expected_marker = format!("{version}\n{expected_integrity}\n"); + + // Invalidate build.rs whenever either cached artifact disappears (cache + // GC, manual rm, OS reset, switching extract dir). Without this, cargo // replays the saved `has_extracted_cli` cfg from its build-script // output cache even when the file is gone, and runtime resolution // fails with BinaryNotFound. - println!("cargo:rerun-if-changed={}", final_path.display()); + for path in &required_paths { + println!("cargo:rerun-if-changed={}", path.display()); + } - if !final_path.is_file() { + let cache_is_current = required_paths.iter().all(|path| path.is_file()) + && std::fs::read_to_string(&required_paths[2]).ok().as_deref() + == Some(expected_marker.as_str()); + if !cache_is_current { + if install_dir.exists() { + std::fs::remove_dir_all(&install_dir).unwrap_or_else(|e| { + panic!( + "failed to clear stale runtime bundle {}: {e}", + install_dir.display() + ) + }); + } let archive = cached_download(&download_url, &cache_key, &expected_integrity, &cache_dir); - verify_binary_present_in_archive(&archive, platform.binary_name, &archive_name); - extract_to_cache(&archive, &install_dir, platform); + verify_runtime_package(&archive, platform, &archive_name); + extract_to_cache( + &archive, + &install_dir, + platform, + include_runtime, + &expected_marker, + ); } // Re-check after potential download+extract above; not an `else` // because we need to verify the extraction actually produced the file. - if final_path.is_file() { + if required_paths.iter().all(|path| path.is_file()) { println!("cargo:rustc-cfg=has_extracted_cli"); } } @@ -176,19 +199,8 @@ fn build_embedded_archive(package: &[u8], platform: Platform, include_runtime: b .mtime(0) .write(Vec::new(), flate2::Compression::default()); let mut archive = tar::Builder::new(encoder); - append_archive_file( - &mut archive, - platform.binary_name, - &extract_binary_bytes(package, platform), - 0o755, - ); + let runtime = append_hostless_runtime_tree(&mut archive, package, platform); if include_runtime { - let runtime = extract_runtime_library_bytes(package).unwrap_or_else(|| { - panic!( - "package `{}` does not contain the native runtime library required by the `bundled-in-process` feature", - platform.package_name - ) - }); append_archive_file( &mut archive, platform.runtime_library_name(), @@ -204,6 +216,103 @@ fn build_embedded_archive(package: &[u8], platform: Platform, include_runtime: b .expect("failed to compress minimal embedded CLI archive") } +fn append_hostless_runtime_tree( + archive: &mut tar::Builder, + package: &[u8], + platform: Platform, +) -> Vec { + let decoder = flate2::read::GzDecoder::new(package); + let mut source = tar::Archive::new(decoder); + let mut runtime = None; + for entry in source + .entries() + .unwrap_or_else(|e| panic!("failed to read npm package entries: {e}")) + { + let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}")); + if !entry.header().entry_type().is_file() { + continue; + } + let source_path = entry + .path() + .unwrap_or_else(|e| panic!("failed to read npm package path: {e}")); + let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform) + else { + continue; + }; + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}")); + let mode = entry.header().mode().unwrap_or(0o644); + if destination == Path::new("runtime.node") { + runtime = Some(bytes.clone()); + } + append_archive_file( + archive, + destination + .to_str() + .expect("npm package paths are valid UTF-8"), + &bytes, + mode, + ); + } + runtime.unwrap_or_else(|| { + panic!( + "package `{}` does not contain prebuilds//runtime.node", + platform.package_name + ) + }) +} + +fn hostless_runtime_path(source: &str, platform: Platform) -> Option { + let relative = source.strip_prefix("package/")?; + let parts: Vec<&str> = relative.split('/').collect(); + if parts.iter().any(|part| part.is_empty() || *part == "..") { + return None; + } + let top_level = *parts.first()?; + let file_name = *parts.last()?; + const EXCLUDED_TOP_LEVEL: &[&str] = &[ + "app.js", + "assets", + "changelog.json", + "copilot-sdk", + "foundry-local-sdk", + "index.js", + "LICENSE.md", + "napi-oop-runtime", + "npm-loader.js", + "package.json", + "preloads", + "pvrecorder", + "queries", + "README.md", + "sdk", + "sea-loader.js", + "webview", + ]; + if EXCLUDED_TOP_LEVEL.contains(&top_level) + || (top_level.starts_with("tree-sitter") && top_level.ends_with(".wasm")) + || (top_level.starts_with("voice-") && top_level.ends_with(".js")) + || file_name == "cli-native.node" + || parts.contains(&"mediaremote-adapter") + || file_name.starts_with("copilot-runtime-bin") + { + return None; + } + if top_level == "prebuilds" { + let npm_platform = platform + .package_name + .strip_prefix("copilot-") + .expect("platform package name has copilot- prefix"); + if parts.get(1) != Some(&npm_platform) || parts.len() < 3 { + return None; + } + return Some(parts[2..].iter().copied().collect()); + } + Some(parts.iter().copied().collect()) +} + fn append_archive_file( archive: &mut tar::Builder, path: &str, @@ -315,6 +424,14 @@ struct Platform { } impl Platform { + fn runtime_wrapper_name(&self) -> &'static str { + if self.package_name.contains("win32") { + "copilot-runtime.exe" + } else { + "copilot-runtime" + } + } + fn runtime_library_name(&self) -> &'static str { if self.package_name.contains("win32") { "copilot_runtime.dll" @@ -368,8 +485,8 @@ fn target_platform() -> Option { } } -/// Write the single binary entry from `archive` to -/// `/` and return the resulting path. +/// Write the runtime wrapper pair from `archive` to `install_dir` and return +/// the wrapper path. /// Idempotent — returns the existing path if a previous build already /// populated the target. /// @@ -378,15 +495,13 @@ fn target_platform() -> Option { /// binary. `fs::rename` for files is atomic on both Unix and Windows /// (Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`); for /// directories it is not, which is why we stage at file granularity. -fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> PathBuf { - let final_path = install_dir.join(platform.binary_name); - - // Caller already gated on `final_path.is_file()`; this is a safety - // net for any future caller that forgets. - if final_path.is_file() { - return final_path; - } - +fn extract_to_cache( + archive: &[u8], + install_dir: &Path, + platform: Platform, + include_runtime: bool, + marker: &str, +) -> PathBuf { std::fs::create_dir_all(install_dir).unwrap_or_else(|e| { panic!( "failed to create install dir {}: {e}", @@ -394,8 +509,90 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P ) }); - let bytes = extract_binary_bytes(archive, platform); + let decoder = flate2::read::GzDecoder::new(archive); + let mut source = tar::Archive::new(decoder); + let mut runtime = None; + for entry in source + .entries() + .unwrap_or_else(|e| panic!("failed to read npm package entries: {e}")) + { + let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}")); + if !entry.header().entry_type().is_file() { + continue; + } + let source_path = entry + .path() + .unwrap_or_else(|e| panic!("failed to read npm package path: {e}")); + let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform) + else { + continue; + }; + if destination == Path::new(platform.binary_name) { + continue; + } + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}")); + let executable = entry.header().mode().unwrap_or(0o644) & 0o111 != 0; + if destination == Path::new("runtime.node") { + runtime = Some(bytes.clone()); + } + install_cached_file_path(install_dir, &destination, &bytes, executable); + } + let runtime = runtime.expect("verified runtime.node is present"); + if include_runtime { + install_cached_file( + install_dir, + platform.runtime_library_name(), + &runtime, + false, + ); + } + install_cached_file( + install_dir, + ".hostless-runtime-assets-v1", + marker.as_bytes(), + false, + ); + + let final_path = install_dir.join(platform.runtime_wrapper_name()); + println!( + "cargo:warning=Extracted Copilot runtime bundle to {}", + install_dir.display() + ); + final_path +} + +fn install_cached_file(install_dir: &Path, file_name: &str, bytes: &[u8], executable: bool) { + install_cached_file_path(install_dir, Path::new(file_name), bytes, executable); +} +fn install_cached_file_path( + install_dir: &Path, + relative_path: &Path, + bytes: &[u8], + executable: bool, +) { + assert!( + !relative_path.is_absolute() + && !relative_path.components().any(|component| { + matches!( + component, + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::ParentDir + ) + }), + "unsafe runtime package path: {}", + relative_path.display() + ); + let final_path = install_dir.join(relative_path); + if final_path.is_file() { + return; + } + std::fs::create_dir_all(final_path.parent().expect("runtime asset has parent")) + .unwrap_or_else(|e| panic!("failed to create runtime asset directory: {e}")); // Staging file is a sibling of the final binary so the rename stays // on the same filesystem (cross-fs rename is not atomic). PID + nanos // disambiguate concurrent builds racing on the same cache. @@ -405,7 +602,10 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P .unwrap_or(0); let staging_path = install_dir.join(format!( ".{}.staging-{}-{nanos}", - platform.binary_name, + relative_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("runtime-asset"), std::process::id(), )); @@ -418,7 +618,7 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P ); }); - if let Err(e) = f.write_all(&bytes) { + if let Err(e) = f.write_all(bytes) { let _ = std::fs::remove_file(&staging_path); panic!( "failed to write staging file {}: {e}", @@ -427,7 +627,7 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P } #[cfg(unix)] - { + if executable { use std::os::unix::fs::PermissionsExt; if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o755)) { let _ = std::fs::remove_file(&staging_path); @@ -472,32 +672,6 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P final_path.display() ); } - - // Surface where the binary landed so contributors can find it. Quiet - // on the hot path: the caller's `is_file()` short-circuit (and the - // safety net at the top of this function) means this only fires on a - // true cache miss. - println!( - "cargo:warning=Extracted Copilot CLI to {}", - final_path.display() - ); - - final_path -} - -fn extract_runtime_library_bytes(archive: &[u8]) -> Option> { - let gz = flate2::read::GzDecoder::new(archive); - let mut tar = tar::Archive::new(gz); - for entry in tar.entries().ok()? { - let mut entry = entry.ok()?; - let name = entry.path().ok()?.to_string_lossy().into_owned(); - if name == "runtime.node" || name.ends_with("/runtime.node") { - let mut bytes = Vec::with_capacity(entry.size() as usize); - entry.read_to_end(&mut bytes).ok()?; - return Some(bytes); - } - } - None } /// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version @@ -514,37 +688,6 @@ fn sanitize_version(version: &str) -> String { .collect() } -/// Extract the single `binary_name` entry from the npm package archive. Reused -/// between embed mode's `verify_binary_present_in_archive` and the -/// `extract_to_cache` path used when `bundled-cli` is off. Panics if the -/// entry isn't found — callers have already invoked -/// `verify_binary_present_in_archive`. -fn extract_binary_bytes(archive: &[u8], platform: Platform) -> Vec { - let gz = flate2::read::GzDecoder::new(archive); - let mut tar = tar::Archive::new(gz); - for entry in tar - .entries() - .unwrap_or_else(|e| panic!("failed to read tar entries: {e}")) - { - let mut entry = entry.unwrap_or_else(|e| panic!("failed to read tar entry: {e}")); - let path = entry - .path() - .unwrap_or_else(|e| panic!("failed to read tar entry path: {e}")); - let name = path.to_string_lossy().into_owned(); - if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) { - let mut bytes = Vec::with_capacity(entry.size() as usize); - entry - .read_to_end(&mut bytes) - .unwrap_or_else(|e| panic!("failed to read tar entry bytes: {e}")); - return bytes; - } - } - panic!( - "binary `{}` not found in package `{}`", - platform.binary_name, platform.package_name - ); -} - /// Read a file from the download cache, or download it (with retries) and save /// to cache. Verifies npm integrity on every path. Evicts stale/corrupt cache entries /// automatically. Cache I/O failures are treated as cache misses — they never @@ -682,15 +825,17 @@ fn try_download(url: &str) -> Result, DownloadError> { } } -/// Walks the downloaded archive at build time to confirm an entry matching -/// `binary_name` exists. Panics with a clear message if not. -fn verify_binary_present_in_archive(archive: &[u8], binary_name: &str, package_name: &str) { - let found = archive_contains_tar_entry(archive, binary_name); - if !found { +fn verify_runtime_package(archive: &[u8], platform: Platform, package_name: &str) { + for file_name in [ + platform.binary_name, + "runtime.node", + platform.runtime_wrapper_name(), + ] { + if archive_contains_tar_entry(archive, file_name) { + continue; + } panic!( - "Copilot CLI package `{package_name}` does not contain an entry named `{binary_name}`. \ - The package layout may have changed; runtime extraction would fail. \ - Update `verify_binary_present_in_archive` in build.rs and the matching `extract_binary` in src/embeddedcli.rs." + "Copilot runtime package `{package_name}` does not contain an entry named `{file_name}`" ); } } diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index 40900a4d22..3cc527a2e2 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -3,10 +3,10 @@ //! feature set). //! //! Normal builds embed the platform release archive from GitHub Releases. -//! Builds with `bundled-in-process` instead embed a minimal archive from the -//! platform npm package containing the CLI executable and native runtime -//! library. Extraction to a real on-disk path is deferred until the first call -//! to [`path`] / [`install_at`]. +//! Builds with `bundled-in-process` instead embed a filtered archive from the +//! platform npm package containing the CLI executable, runtime wrapper, native +//! runtime artifacts, and auxiliary runtime assets. Extraction to a real +//! on-disk path is deferred until the relevant installer is called. //! //! The embedded bytes are part of the consumer's signed binary and therefore //! trusted *as the source of truth* — but the bytes that land on disk are not. @@ -28,7 +28,7 @@ // off but still needs to exercise them. #[cfg(any(has_bundled_cli, test))] use std::fs; -#[cfg(all(has_bundled_cli, any(feature = "bundled-in-process", not(windows))))] +#[cfg(has_bundled_cli)] use std::io::Read; #[cfg(any(has_bundled_cli, test))] use std::io::Write; @@ -65,9 +65,19 @@ const CLI_VERSION: &str = env!("COPILOT_SDK_CLI_VERSION"); const CLI_BINARY_NAME: &str = "copilot.exe"; #[cfg(all(has_bundled_cli, not(windows)))] const CLI_BINARY_NAME: &str = "copilot"; +#[cfg(all(has_bundled_cli, windows))] +const RUNTIME_BINARY_NAME: &str = "copilot-runtime.exe"; +#[cfg(all(has_bundled_cli, not(windows)))] +const RUNTIME_BINARY_NAME: &str = "copilot-runtime"; +#[cfg(has_bundled_cli)] +const RUNTIME_NODE_NAME: &str = "runtime.node"; +#[cfg(has_bundled_cli)] +const RUNTIME_VERSION_MARKER: &str = ".copilot-runtime-version"; #[cfg(feature = "bundled-cli")] static INSTALLED_PATH: OnceLock> = OnceLock::new(); +#[cfg(feature = "bundled-cli")] +static INSTALLED_RUNTIME_PATH: OnceLock> = OnceLock::new(); /// Returns the path to the installed CLI binary, lazily extracting the /// embedded archive on first call. @@ -91,7 +101,7 @@ pub(crate) fn path() -> Option { #[cfg(has_bundled_cli)] { let dir = default_install_dir(CLI_VERSION); - match install(&dir, build_time::CLI_ARCHIVE) { + match install_cli_bundle(&dir, build_time::CLI_ARCHIVE) { Ok(path) => { info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed"); return Some(path); @@ -119,7 +129,7 @@ pub(crate) fn path() -> Option { pub(crate) fn install_at(extract_dir: &Path) -> Option { #[cfg(has_bundled_cli)] { - match install(extract_dir, build_time::CLI_ARCHIVE) { + match install_cli_bundle(extract_dir, build_time::CLI_ARCHIVE) { Ok(path) => { info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed"); return Some(path); @@ -136,6 +146,93 @@ pub(crate) fn install_at(extract_dir: &Path) -> Option { None } +/// Returns the path to the bundled runtime wrapper, extracting the wrapper and +/// adjacent `runtime.node` on first call. +#[cfg(feature = "bundled-cli")] +pub(crate) fn runtime_path() -> Option { + INSTALLED_RUNTIME_PATH + .get_or_init(|| { + #[cfg(has_bundled_cli)] + { + let dir = default_install_dir(CLI_VERSION); + match install_runtime(&dir, build_time::CLI_ARCHIVE) { + Ok(path) => { + info!(path = %path.display(), version = CLI_VERSION, "embedded runtime installed"); + return Some(path); + } + Err(e) => { + warn!(error = %e, "embedded runtime installation failed"); + } + } + } + None + }) + .clone() +} + +/// Installs the bundled runtime wrapper and adjacent `runtime.node` into a +/// caller-specified directory. +#[cfg(feature = "bundled-cli")] +pub(crate) fn install_runtime_at(extract_dir: &Path) -> Option { + #[cfg(has_bundled_cli)] + { + let install_dir = match runtime_install_dir(extract_dir, CLI_VERSION) { + Ok(dir) => dir, + Err(e) => { + warn!(error = %e, "embedded runtime install directory selection failed"); + return None; + } + }; + match install_runtime(&install_dir, build_time::CLI_ARCHIVE) { + Ok(path) => { + info!(path = %path.display(), version = CLI_VERSION, "embedded runtime installed"); + return Some(path); + } + Err(e) => { + warn!(error = %e, "embedded runtime installation failed"); + } + } + } + #[cfg(not(has_bundled_cli))] + { + let _ = extract_dir; + } + None +} + +#[cfg(has_bundled_cli)] +fn runtime_install_dir(base_dir: &Path, version: &str) -> Result { + fs::create_dir_all(base_dir) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; + let marker = base_dir.join(RUNTIME_VERSION_MARKER); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker) + { + Ok(mut file) => { + if let Err(error) = file + .write_all(version.as_bytes()) + .and_then(|()| file.sync_all()) + { + drop(file); + let _ = fs::remove_file(&marker); + return Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, error)); + } + Ok(base_dir.to_path_buf()) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let installed_version = fs::read_to_string(marker).unwrap_or_default(); + if installed_version == version { + Ok(base_dir.to_path_buf()) + } else { + Ok(base_dir.join(version)) + } + } + Err(error) => Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, error)), + } +} + #[cfg(has_bundled_cli)] fn default_install_dir(version: &str) -> PathBuf { let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); @@ -168,34 +265,151 @@ const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.dylib"; const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.so"; #[cfg(has_bundled_cli)] -fn install(install_dir: &Path, archive: &[u8]) -> Result { - let final_path = install_cli(install_dir, archive)?; +fn install_cli_bundle(install_dir: &Path, archive: &[u8]) -> Result { + install_cli(install_dir, archive)?; + install_hostless_assets(install_dir, archive)?; #[cfg(feature = "bundled-in-process")] { install_runtime_library(install_dir, archive)?; } - Ok(final_path) + Ok(install_dir.join(CLI_BINARY_NAME)) +} + +#[cfg(has_bundled_cli)] +fn install_runtime(install_dir: &Path, archive: &[u8]) -> Result { + fs::create_dir_all(install_dir) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; + install_hostless_assets(install_dir, archive)?; + install_runtime_pair(install_dir, archive)?; + Ok(install_dir.join(RUNTIME_BINARY_NAME)) +} + +#[cfg(has_bundled_cli)] +fn install_hostless_assets(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar + .entries() + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))? + { + let mut entry = + entry.map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry + .path() + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))? + .into_owned(); + let file_name = path.file_name().and_then(|name| name.to_str()); + if path == Path::new(CLI_BINARY_NAME) + || matches!( + file_name, + Some("copilot_runtime.dll") + | Some("libcopilot_runtime.dylib") + | Some("libcopilot_runtime.so") + ) + { + continue; + } + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::ParentDir + ) + }) + { + return Err(EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Archive, + format!("unsafe embedded runtime asset path: {}", path.display()), + )); + } + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + let target = install_dir.join(&path); + if fs::read(&target) + .map(|installed| installed == bytes) + .unwrap_or(false) + { + continue; + } + let parent = target.parent().ok_or_else(|| { + EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Archive, + format!("embedded runtime asset has no parent: {}", path.display()), + ) + })?; + fs::create_dir_all(parent) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; + let tmp = write_temp_file(parent, &bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = entry.header().mode().unwrap_or(0o644) & 0o777; + fs::set_permissions(&tmp, fs::Permissions::from_mode(mode)) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; + } + if let Err(error) = publish(&tmp, &target) { + let _ = fs::remove_file(&tmp); + return Err(error); + } + } + Ok(()) +} + +#[cfg(has_bundled_cli)] +fn install_runtime_pair(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { + install_adjacent_file(install_dir, archive, RUNTIME_NODE_NAME, "runtime.node")?; + install_adjacent_file( + install_dir, + archive, + RUNTIME_BINARY_NAME, + "copilot runtime wrapper", + ) } #[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] fn install_runtime_library(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { - let target = install_dir.join(RUNTIME_LIBRARY_NAME); - if fs::metadata(&target).map(|m| m.len() > 0).unwrap_or(false) { - return Ok(()); - } - let bytes = extract_binary(archive, RUNTIME_LIBRARY_NAME)?; + install_adjacent_file( + install_dir, + archive, + RUNTIME_LIBRARY_NAME, + "in-process FFI runtime library", + ) +} + +#[cfg(has_bundled_cli)] +fn install_adjacent_file( + install_dir: &Path, + archive: &[u8], + file_name: &str, + label: &str, +) -> Result<(), EmbeddedCliError> { + let target = install_dir.join(file_name); + let bytes = extract_binary(archive, file_name)?; if bytes.is_empty() { return Err(EmbeddedCliError::with_message( EmbeddedCliErrorKind::Verification, - "embedded runtime library is empty", + format!("embedded {label} is empty"), )); } + if fs::read(&target) + .map(|installed| installed == bytes) + .unwrap_or(false) + { + return Ok(()); + } let tmp = write_temp_file(install_dir, &bytes)?; if let Err(e) = publish(&tmp, &target) { let _ = fs::remove_file(&tmp); return Err(e); } - tracing::debug!(path = %target.display(), "in-process FFI runtime library installed"); + tracing::debug!(path = %target.display(), %label, "embedded runtime artifact installed"); Ok(()) } @@ -480,7 +694,7 @@ fn read_marker_len(marker_path: &Path) -> Option { .ok() } -#[cfg(all(has_bundled_cli, any(feature = "bundled-in-process", not(windows))))] +#[cfg(has_bundled_cli)] fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { let gz = flate2::read::GzDecoder::new(archive); let mut tar = tar::Archive::new(gz); @@ -505,26 +719,6 @@ fn extract_binary(archive: &[u8], binary_name: &str) -> Result, Embedded Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into()) } -#[cfg(all(has_bundled_cli, not(feature = "bundled-in-process"), windows))] -fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { - let cursor = std::io::Cursor::new(archive); - let mut zip = zip::ZipArchive::new(cursor) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Zip, e))?; - for i in 0..zip.len() { - let mut entry = zip - .by_index(i) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Zip, e))?; - let name = entry.name().to_string(); - if name == binary_name || name.ends_with(&format!("/{binary_name}")) { - let mut bytes = Vec::with_capacity(entry.size() as usize); - std::io::copy(&mut entry, &mut bytes) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; - return Ok(bytes); - } - } - Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into()) -} - #[cfg(has_bundled_cli)] fn sanitize_version(version: &str) -> String { version @@ -541,10 +735,7 @@ fn sanitize_version(version: &str) -> String { #[allow(dead_code)] enum EmbeddedCliErrorKind { CreateDir, - #[cfg(any(feature = "bundled-in-process", not(windows)))] Archive, - #[cfg(all(not(feature = "bundled-in-process"), windows))] - Zip, BinaryNotFoundInArchive, Io, /// Atomically renaming the staged temp file onto the final path failed. @@ -561,10 +752,7 @@ impl std::fmt::Display for EmbeddedCliErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { EmbeddedCliErrorKind::CreateDir => f.write_str("failed to create install directory"), - #[cfg(any(feature = "bundled-in-process", not(windows)))] EmbeddedCliErrorKind::Archive => f.write_str("failed to read archive entry"), - #[cfg(all(not(feature = "bundled-in-process"), windows))] - EmbeddedCliErrorKind::Zip => f.write_str("failed to read zip archive"), EmbeddedCliErrorKind::BinaryNotFoundInArchive => { f.write_str("CLI binary not found in embedded archive") } @@ -670,7 +858,7 @@ mod tests { #[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] #[test] - fn embedded_archive_contains_only_expected_files() { + fn embedded_archive_contains_runtime_assets_and_excludes_cli_only_files() { let gz = flate2::read::GzDecoder::new(build_time::CLI_ARCHIVE); let mut archive = tar::Archive::new(gz); let mut names: Vec = archive @@ -687,12 +875,13 @@ mod tests { .collect(); names.sort(); - let mut expected = vec![ - CLI_BINARY_NAME.to_string(), - RUNTIME_LIBRARY_NAME.to_string(), - ]; - expected.sort(); - assert_eq!(names, expected); + assert!(names.contains(&CLI_BINARY_NAME.to_string())); + assert!(names.contains(&RUNTIME_LIBRARY_NAME.to_string())); + assert!(names.contains(&RUNTIME_BINARY_NAME.to_string())); + assert!(names.contains(&RUNTIME_NODE_NAME.to_string())); + assert!(names.iter().any(|name| name.starts_with("ripgrep/"))); + assert!(names.iter().any(|name| name.starts_with("definitions/"))); + assert!(!names.contains(&"app.js".to_string())); } /// Bytes whose header looks like a valid executable image on the host @@ -841,4 +1030,42 @@ mod tests { assert_eq!(mode & 0o777, 0o755, "temp binary should be executable"); } } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_install_replaces_stale_pair() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join(RUNTIME_NODE_NAME), b"stale runtime").expect("seed runtime"); + fs::write(dir.path().join(RUNTIME_BINARY_NAME), b"stale wrapper").expect("seed wrapper"); + + install_runtime(dir.path(), build_time::CLI_ARCHIVE).expect("install runtime"); + + assert_eq!( + fs::read(dir.path().join(RUNTIME_NODE_NAME)).expect("read runtime"), + extract_binary(build_time::CLI_ARCHIVE, RUNTIME_NODE_NAME).expect("extract runtime") + ); + assert_eq!( + fs::read(dir.path().join(RUNTIME_BINARY_NAME)).expect("read wrapper"), + extract_binary(build_time::CLI_ARCHIVE, RUNTIME_BINARY_NAME).expect("extract wrapper") + ); + } + + #[cfg(has_bundled_cli)] + #[test] + fn custom_runtime_install_dir_isolated_by_version() { + let dir = tempfile::tempdir().expect("tempdir"); + + assert_eq!( + runtime_install_dir(dir.path(), "1.0.0").expect("claim directory"), + dir.path() + ); + assert_eq!( + runtime_install_dir(dir.path(), "1.0.0").expect("reuse directory"), + dir.path() + ); + assert_eq!( + runtime_install_dir(dir.path(), "2.0.0").expect("isolate directory"), + dir.path().join("2.0.0") + ); + } } diff --git a/rust/src/extension_launch_provider.rs b/rust/src/extension_launch_provider.rs new file mode 100644 index 0000000000..63e2284144 --- /dev/null +++ b/rust/src/extension_launch_provider.rs @@ -0,0 +1,158 @@ +//! Connection-level extension launch profile resolution. + +use std::sync::{Arc, OnceLock, Weak}; + +use async_trait::async_trait; +use parking_lot::RwLock; +use serde::Serialize; +use serde_json::Value; +use tracing::warn; + +pub use crate::rpc::{ + ExtensionLaunchProfile, ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, +}; +use crate::{ + Client, ClientInner, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Result, error_codes, +}; + +pub(crate) const RESOLVE_METHOD: &str = "extensionLaunchProvider.resolve"; +const MISSING_HANDLER_MESSAGE: &str = "No extensionLaunchProvider client-global handler registered"; + +/// Resolves process launch profiles for extension entrypoints discovered by the runtime. +/// +/// Configure an implementation with +/// [`ClientOptions::with_extension_launch_provider`](crate::ClientOptions::with_extension_launch_provider). +/// The SDK registers the provider before [`Client::start`](crate::Client::start) +/// returns, so extension resolution cannot race session creation. +/// +/// The returned executable, arguments, and environment are forwarded unchanged. +/// The runtime remains authoritative for its reserved `COPILOT_SDK_PATH`, +/// `SESSION_ID`, and `COPILOT_EXTENSION_PARENT_PID` environment variables. +#[async_trait] +pub trait ExtensionLaunchProvider: Send + Sync + 'static { + /// Resolve a launch profile for one discovered extension entrypoint. + /// + /// Return a result with `launch: None` when the provider does not support + /// the entrypoint. + async fn resolve( + &self, + request: ExtensionLaunchProviderResolveRequest, + ) -> Result; +} + +pub(crate) struct ExtensionLaunchProviderDispatcher { + handler: RwLock>>, + client: OnceLock>, +} + +impl ExtensionLaunchProviderDispatcher { + pub(crate) fn new(handler: Option>) -> Self { + Self { + handler: RwLock::new(handler), + client: OnceLock::new(), + } + } + + pub(crate) fn set_client(&self, client: Weak) { + let _ = self.client.set(client); + } + + pub(crate) fn is_configured(&self) -> bool { + self.handler.read().is_some() + } + + pub(crate) fn clear(&self) { + self.handler.write().take(); + } + + pub(crate) async fn dispatch(&self, request: JsonRpcRequest) { + let request_id = request.id; + let Some(handler) = self.handler.read().clone() else { + self.send_error( + request_id, + error_codes::INTERNAL_ERROR, + MISSING_HANDLER_MESSAGE, + ) + .await; + return; + }; + + let params = request + .params + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + let request = match serde_json::from_value(params) { + Ok(request) => request, + Err(error) => { + self.send_error( + request_id, + error_codes::INVALID_PARAMS, + &format!("invalid params: {error}"), + ) + .await; + return; + } + }; + + match handler.resolve(request).await { + Ok(result) => self.respond(request_id, result).await, + Err(error) => { + self.send_error(request_id, error_codes::INTERNAL_ERROR, &error.to_string()) + .await; + } + } + } + + fn client(&self) -> Option { + self.client + .get() + .and_then(Weak::upgrade) + .map(Client::from_inner) + } + + async fn respond(&self, request_id: u64, result: T) { + let value = match serde_json::to_value(result) { + Ok(value) => value, + Err(error) => { + warn!(error = %error, "failed to serialize extension launch provider response"); + self.send_error( + request_id, + error_codes::INTERNAL_ERROR, + "serialization failure", + ) + .await; + return; + } + }; + + let Some(client) = self.client() else { + return; + }; + let _ = client + .send_response(&JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: Some(value), + error: None, + }) + .await; + } + + async fn send_error(&self, request_id: u64, code: i32, message: &str) { + let Some(client) = self.client() else { + return; + }; + let _ = client + .send_response(&JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id, + result: None, + error: Some(JsonRpcError { + code, + message: message.to_string(), + data: None, + }), + }) + .await; + } +} diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index f784b1a6d1..a25990062c 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -2,12 +2,11 @@ //! library and speaking JSON-RPC over its C ABI, //! instead of spawning a CLI child process and communicating over stdio/TCP. //! -//! The runtime's `host_start` export spawns the residual TypeScript worker -//! itself — the packaged single-file CLI (`copilot --embedded-host`) or, for -//! dev, `node dist-cli/index.js --embedded-host`. JSON-RPC frames are pumped -//! across the ABI: writes go to `connection_write`; inbound frames arrive on a -//! native callback that feeds an async reader. The framing is unchanged — the -//! same LSP `Content-Length:` frames the stdio transport uses. +//! The runtime's `host_start` export constructs the Rust server synchronously in +//! this process. JSON-RPC frames are pumped across the ABI: writes go to +//! `connection_write`; inbound frames arrive on a native callback that feeds an +//! async reader. The framing is unchanged — the same LSP `Content-Length:` +//! frames the stdio transport uses. use std::collections::HashMap; use std::ffi::c_void; @@ -204,12 +203,11 @@ impl AsyncWrite for FfiWriter { } } -/// Prepared FFI host: the bound cdylib exports plus the spawn arguments needed -/// to start the runtime worker. The cdylib is loaded process-globally and never -/// unloaded (see [`load_library`]). +/// Prepared FFI host. The cdylib is loaded process-globally and never unloaded +/// (see [`load_library`]). pub(crate) struct FfiHost { library_path: PathBuf, - entrypoint: PathBuf, + cli_entrypoint: Option, environment: Vec<(String, String)>, args: Vec, host_start: HostStartFn, @@ -224,30 +222,34 @@ pub(crate) struct FfiHost { unsafe impl Send for FfiHost {} impl FfiHost { - /// Load the cdylib next to `entrypoint` and bind its exports. - /// - /// `entrypoint` is the packaged single-file CLI binary or, for dev, a - /// `.js` file launched via `node`. The native library is resolved relative - /// to the entrypoint directory, supporting both packaged and development - /// layouts. + /// Load the cdylib next to `runtime_entrypoint` and bind its exports. pub(crate) fn create( - entrypoint: &Path, + runtime_entrypoint: &Path, + cli_entrypoint: Option<&Path>, environment: Vec<(String, String)>, args: Vec, ) -> Result { - let entrypoint = std::fs::canonicalize(entrypoint) - .map(path_for_child_process) + let runtime_entrypoint = std::fs::canonicalize(runtime_entrypoint).map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "failed to resolve in-process runtime entrypoint '{}': {e}", + runtime_entrypoint.display() + ), + ) + })?; + let cli_entrypoint = cli_entrypoint + .map(std::fs::canonicalize) + .transpose() .map_err(|e| { Error::with_message( ErrorKind::InvalidConfig, - format!( - "failed to resolve in-process CLI entrypoint '{}': {e}", - entrypoint.display() - ), + format!("failed to resolve explicit in-process CLI entrypoint: {e}"), ) - })?; - let library_path = - std::fs::canonicalize(resolve_library_path(&entrypoint)?).map_err(|e| { + })? + .map(path_for_child_process); + let library_path = std::fs::canonicalize(resolve_library_path(&runtime_entrypoint)?) + .map_err(|e| { Error::with_message( ErrorKind::InvalidConfig, format!("failed to resolve in-process runtime library: {e}"), @@ -267,7 +269,7 @@ impl FfiHost { Ok(Self { library_path, - entrypoint, + cli_entrypoint, environment, args, host_start, @@ -278,11 +280,7 @@ impl FfiHost { }) } - /// Start the runtime worker and open the FFI JSON-RPC connection. - /// - /// `host_start` blocks until the worker connects back and signals - /// readiness (up to ~30s), and must not run on an async executor thread, so - /// the blocking handshake is offloaded to [`tokio::task::spawn_blocking`]. + /// Start the native runtime and open the FFI JSON-RPC connection. pub(crate) async fn start(self) -> Result<(FfiReader, FfiWriter, Arc), Error> { tokio::task::spawn_blocking(move || self.start_blocking()) .await @@ -295,7 +293,7 @@ impl FfiHost { } fn start_blocking(self) -> Result<(FfiReader, FfiWriter, Arc), Error> { - let argv = build_argv_json(&self.entrypoint, &self.args); + let argv = build_argv_json(self.cli_entrypoint.as_deref(), &self.args); let env = build_env_json(&self.environment); let (env_ptr, env_len) = match &env { @@ -309,9 +307,8 @@ impl FfiHost { return Err(Error::with_message( ErrorKind::InvalidConfig, format!( - "copilot_runtime_host_start failed (library '{}', entrypoint '{}')", - self.library_path.display(), - self.entrypoint.display() + "copilot_runtime_host_start failed (library '{}')", + self.library_path.display() ), )); } @@ -440,6 +437,8 @@ pub(crate) fn prebuilds_folder() -> Option { "win32" } else if cfg!(target_os = "macos") { "darwin" + } else if cfg!(all(target_os = "linux", target_env = "musl")) { + "linuxmusl" } else if cfg!(target_os = "linux") { "linux" } else { @@ -472,6 +471,11 @@ fn resolve_library_path(entrypoint: &Path) -> Result { return Ok(flat); } + let adjacent = dir.join("runtime.node"); + if adjacent.is_file() { + return Ok(adjacent); + } + // Development package layout. let prebuilds = prebuilds_folder().map(|folder| dir.join("prebuilds").join(folder).join("runtime.node")); @@ -521,28 +525,23 @@ fn path_for_child_process(path: PathBuf) -> PathBuf { path } -fn build_argv_json(entrypoint: &Path, extra_args: &[String]) -> Vec { - // A `.js` entrypoint (dev / dist-cli) is launched via node; the packaged - // single-file CLI binary embeds its own Node and is invoked directly. - let entrypoint_str = entrypoint.to_string_lossy().into_owned(); - let is_js = entrypoint - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("js")); - let mut argv: Vec = if is_js { - vec![ - "node".to_string(), - entrypoint_str, - "--embedded-host".to_string(), - "--no-auto-update".to_string(), - ] - } else { - vec![ +fn build_argv_json(entrypoint: Option<&Path>, extra_args: &[String]) -> Vec { + let mut argv = Vec::new(); + if let Some(entrypoint) = entrypoint { + let entrypoint_str = entrypoint.to_string_lossy().into_owned(); + if entrypoint + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("js")) + { + argv.push("node".to_string()); + } + argv.extend([ entrypoint_str, "--embedded-host".to_string(), "--no-auto-update".to_string(), - ] - }; + ]); + } argv.extend_from_slice(extra_args); serde_json::to_vec(&argv).expect("argv serializes") } @@ -563,29 +562,20 @@ mod tests { use super::*; #[test] - fn argv_pins_worker_and_appends_client_options() { + fn argv_without_entrypoint_contains_only_client_options() { let argv: Vec = serde_json::from_slice(&build_argv_json( - Path::new("copilot"), + None, &["--log-level".into(), "debug".into()], )) .unwrap(); - assert_eq!( - argv, - [ - "copilot", - "--embedded-host", - "--no-auto-update", - "--log-level", - "debug" - ] - ); + assert_eq!(argv, ["--log-level", "debug"]); } #[test] - fn javascript_entrypoint_uses_node() { + fn explicit_javascript_entrypoint_uses_node() { let argv: Vec = - serde_json::from_slice(&build_argv_json(Path::new("index.js"), &[])).unwrap(); + serde_json::from_slice(&build_argv_json(Some(Path::new("index.js")), &[])).unwrap(); assert_eq!( argv, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4a9f73ca4c..1c20fd1837 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -10,6 +10,8 @@ mod canvas_dispatch; #[cfg(feature = "bundled-cli")] pub(crate) mod embeddedcli; mod errors; +/// Connection-level extension launch profile provider. +pub mod extension_launch_provider; /// In-process FFI transport hosting the runtime cdylib (`Transport::InProcess`). #[cfg(feature = "bundled-in-process")] pub(crate) mod ffi; @@ -179,8 +181,10 @@ pub enum Transport { /// How the SDK locates the GitHub Copilot CLI binary. #[derive(Debug, Clone, Default)] pub enum CliProgram { - /// Auto-resolve: `COPILOT_CLI_PATH` → embedded CLI → dev cache. - /// This is the default. + /// Auto-resolve the transport's program. Managed child-process transports + /// select `COPILOT_CLI_PATH`, then the bundled runtime wrapper. In-process + /// transport loads the wrapper's adjacent runtime library directly unless + /// `COPILOT_CLI_PATH` explicitly selects a legacy embedded host. #[default] Resolve, /// Use an explicit binary path (skips resolution). @@ -204,12 +208,9 @@ pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli); /// Returns the path to the bundled Copilot CLI, extracting it from the /// embedded archive on first call. /// -/// This is the same path [`Client::start`] resolves to when -/// [`ClientOptions::program`] is [`CliProgram::Resolve`], no -/// `COPILOT_CLI_PATH` override is set, and no -/// [`ClientOptions::bundled_cli_extract_dir`] is configured — exposing -/// it directly so callers (health checks, diagnostics, version probes) -/// can reach the bundled binary without spinning up a full [`Client`]. +/// This exposes the CLI artifact directly for callers such as health checks, +/// diagnostics, version probes, and in-process hosting. Managed child-process +/// transports resolve the bundled `copilot-runtime` wrapper instead. /// /// Subsequent calls return the cached result. Extraction is skipped when /// an already-published binary passes a cheap integrity re-check; a @@ -235,12 +236,35 @@ pub fn install_bundled_cli() -> Option { } } +/// Returns the path to the bundled `copilot-runtime` executable, extracting it +/// with adjacent `runtime.node` on first call. +/// +/// This is intended for health checks and intermediate launchers that need the +/// concrete managed runtime path before [`Client::start`]. Subsequent calls +/// return the cached result. +/// +/// Returns `None` when the `bundled-cli` feature is off, the target platform +/// isn't supported, or extraction failed. It does not fall back to the +/// build-time extraction cache. +pub fn install_bundled_runtime() -> Option { + #[cfg(feature = "bundled-cli")] + { + embeddedcli::runtime_path() + } + #[cfg(not(feature = "bundled-cli"))] + { + None + } +} + /// Options for starting a [`Client`]. /// /// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`] -/// uses `COPILOT_CLI_PATH` when set to a real file. Otherwise it uses the -/// bundled Copilot CLI when the default `bundled-cli` cargo feature is enabled, -/// or the build-time extracted dev-cache CLI when that feature is disabled. +/// uses `COPILOT_CLI_PATH` when set to a real file. Managed child-process +/// transports next use the bundled `copilot-runtime` wrapper. In-process +/// transport loads the wrapper's adjacent runtime library. With `bundled-cli` +/// disabled, the corresponding artifact is resolved from the build-time +/// extraction cache. /// /// Set `program` to [`CliProgram::Path`] to use an explicit binary instead. /// This skips auto-resolution entirely. @@ -311,6 +335,14 @@ pub struct ClientOptions { /// [`CopilotRequestHandler`] /// instead of issuing the calls itself. pub request_handler: Option>, + /// Connection-level extension launch profile provider. + /// + /// When set, the SDK registers itself with the runtime during + /// [`Client::start`] before any session can be created. Incoming + /// `extensionLaunchProvider.resolve` requests are dispatched independently + /// of sessions. + pub extension_launch_provider: + Option>, /// Connection-level GitHub telemetry forwarding callback (experimental). /// /// When set, every session created or resumed on this client opts into @@ -403,6 +435,10 @@ impl std::fmt::Debug for ClientOptions { "request_handler", &self.request_handler.as_ref().map(|_| ""), ) + .field( + "extension_launch_provider", + &self.extension_launch_provider.as_ref().map(|_| ""), + ) .field( "on_github_telemetry", &self.on_github_telemetry.as_ref().map(|_| ""), @@ -656,6 +692,7 @@ impl Default for ClientOptions { on_list_models: None, session_fs: None, request_handler: None, + extension_launch_provider: None, on_github_telemetry: None, on_get_trace_context: None, telemetry: None, @@ -814,6 +851,18 @@ impl ClientOptions { self } + /// Register a connection-level extension launch profile provider. + /// + /// The provider is wrapped in [`Arc`] internally and registered with the + /// runtime before [`Client::start`] returns. + pub fn with_extension_launch_provider

(mut self, provider: P) -> Self + where + P: crate::extension_launch_provider::ExtensionLaunchProvider, + { + self.extension_launch_provider = Some(Arc::new(provider)); + self + } + /// Register a connection-level GitHub telemetry forwarding callback /// (internal/experimental). Registering a callback auto-enables telemetry /// forwarding on every session created or resumed on this client; the @@ -859,8 +908,8 @@ impl ClientOptions { self } - /// Override the directory where the bundled CLI binary is extracted on - /// first use. See [`Self::bundled_cli_extract_dir`]. + /// Override the directory where bundled CLI and runtime artifacts are + /// extracted on first use. See [`Self::bundled_cli_extract_dir`]. /// /// Only applies when the `bundled-cli` cargo feature is on. With /// `bundled-cli` disabled (`default-features = false`), set @@ -1037,6 +1086,7 @@ struct ClientInner { /// Inbound `llmInference.*` dispatcher, installed when /// [`ClientOptions::request_handler`] is set. llm_inference: OnceLock>, + extension_launch_provider: Arc, /// Connection-level GitHub telemetry forwarding callback, set from /// [`ClientOptions::on_github_telemetry`]. Drives the /// `enableGitHubTelemetryForwarding` wire flag and the @@ -1182,6 +1232,7 @@ impl Client { }; let session_fs_config = options.session_fs.clone(); let request_handler = options.request_handler.clone(); + let extension_launch_provider = options.extension_launch_provider.clone(); let session_fs_sqlite_declared = session_fs_config .as_ref() .and_then(|c| c.capabilities.as_ref()) @@ -1195,6 +1246,7 @@ impl Client { let resolve_start = Instant::now(); let resolved = resolve::copilot_binary_with_extract_dir( options.bundled_cli_extract_dir.as_deref(), + true, )?; let resolve_elapsed = resolve_start.elapsed(); timings.program_resolve_ms = Some(StartupTimings::millis(resolve_elapsed)); @@ -1202,7 +1254,7 @@ impl Client { elapsed_ms = resolve_elapsed.as_millis(), "Client::start CLI program resolution complete" ); - info!(path = %resolved.display(), "resolved copilot CLI"); + info!(path = %resolved.display(), "resolved copilot runtime"); #[cfg(windows)] { if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| { @@ -1252,6 +1304,7 @@ impl Client { None, working_directory, options.on_list_models, + extension_launch_provider.clone(), session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, @@ -1283,6 +1336,7 @@ impl Client { Some(child), working_directory, options.on_list_models, + extension_launch_provider.clone(), session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, @@ -1304,6 +1358,7 @@ impl Client { Some(child), working_directory, options.on_list_models, + extension_launch_provider.clone(), session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, @@ -1353,7 +1408,15 @@ impl Client { if !use_logged_in_user { args.push("--no-auto-login".to_string()); } - let host = crate::ffi::FfiHost::create(&program, environment, args)?; + let explicit_cli = std::env::var_os("COPILOT_CLI_PATH") + .map(PathBuf::from) + .filter(|path| path.is_file()); + let host = crate::ffi::FfiHost::create( + &program, + explicit_cli.as_deref(), + environment, + args, + )?; let (reader, writer, shared) = host.start().await?; let client = Self::from_transport( reader, @@ -1361,6 +1424,7 @@ impl Client { None, working_directory, options.on_list_models, + extension_launch_provider.clone(), session_fs_config.is_some(), session_fs_sqlite_declared, options.on_get_trace_context, @@ -1387,6 +1451,25 @@ impl Client { elapsed_ms = start_time.elapsed().as_millis(), "Client::start protocol verification complete" ); + let request_dispatcher = request_handler.map(|handler| { + let dispatcher = Arc::new(copilot_request_handler::CopilotRequestDispatcher::new( + handler, + )); + dispatcher.set_client(Arc::downgrade(&client.inner)); + let _ = client.inner.llm_inference.set(dispatcher.clone()); + dispatcher + }); + if client.inner.extension_launch_provider.is_configured() { + client.inner.router.ensure_started( + &client.inner.notification_tx, + &client.inner.request_rx, + client.inner.extension_launch_provider.clone(), + request_dispatcher.clone(), + client.inner.on_github_telemetry.clone(), + client.inner.github_token_registry.clone(), + ); + client.rpc().register_extension_launch_provider().await?; + } if !builtin_plugin_directories.is_empty() { client .call( @@ -1416,18 +1499,14 @@ impl Client { "Client::start session filesystem setup complete" ); } - if let Some(handler) = request_handler { + if let Some(dispatcher) = request_dispatcher { let llm_inference_start = Instant::now(); - let dispatcher = Arc::new(copilot_request_handler::CopilotRequestDispatcher::new( - handler, - )); - dispatcher.set_client(Arc::downgrade(&client.inner)); - let _ = client.inner.llm_inference.set(dispatcher.clone()); // Start the router early (before any session is registered) so the // startup model catalog request is dispatched to the handler. client.inner.router.ensure_started( &client.inner.notification_tx, &client.inner.request_rx, + client.inner.extension_launch_provider.clone(), Some(dispatcher.clone()), client.inner.on_github_telemetry.clone(), client.inner.github_token_registry.clone(), @@ -1486,6 +1565,33 @@ impl Client { None, cwd, None, + None, + false, + false, + None, + None, + None, + ClientMode::default(), + ) + } + + /// Construct a [`Client`] from raw streams with a preset extension launch + /// provider, for integration testing connection-global reverse requests. + #[doc(hidden)] + #[cfg(any(test, feature = "test-support"))] + pub fn from_streams_with_extension_launch_provider( + reader: impl AsyncRead + Unpin + Send + 'static, + writer: impl AsyncWrite + Unpin + Send + 'static, + cwd: PathBuf, + provider: Arc, + ) -> Result { + Self::from_transport( + reader, + writer, + None, + cwd, + None, + Some(provider), false, false, None, @@ -1515,6 +1621,7 @@ impl Client { None, cwd, None, + None, false, false, Some(provider), @@ -1540,6 +1647,7 @@ impl Client { None, cwd, None, + None, false, false, None, @@ -1565,6 +1673,7 @@ impl Client { None, cwd, None, + None, false, false, None, @@ -1591,6 +1700,9 @@ impl Client { child: Option, cwd: PathBuf, on_list_models: Option>, + extension_launch_provider: Option< + Arc, + >, session_fs_configured: bool, session_fs_sqlite_declared: bool, on_get_trace_context: Option>, @@ -1612,6 +1724,11 @@ impl Client { info!(pid = ?pid, "copilot CLI client ready"); let github_token_registry = Arc::new(github_token::GitHubTokenRegistry::new()); + let extension_launch_provider = Arc::new( + extension_launch_provider::ExtensionLaunchProviderDispatcher::new( + extension_launch_provider, + ), + ); let client = Self { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(child), @@ -1631,6 +1748,7 @@ impl Client { session_fs_configured, session_fs_sqlite_declared, llm_inference: OnceLock::new(), + extension_launch_provider: extension_launch_provider.clone(), on_github_telemetry, on_get_trace_context, effective_connection_token, @@ -1639,6 +1757,7 @@ impl Client { }), }; github_token_registry.set_client(Arc::downgrade(&client.inner)); + extension_launch_provider.set_client(Arc::downgrade(&client.inner)); client.spawn_lifecycle_dispatcher(); debug!( elapsed_ms = setup_start.elapsed().as_millis(), @@ -2055,6 +2174,7 @@ impl Client { self.inner.router.ensure_started( &self.inner.notification_tx, &self.inner.request_rx, + self.inner.extension_launch_provider.clone(), self.inner.llm_inference.get().cloned(), self.inner.on_github_telemetry.clone(), self.inner.github_token_registry.clone(), @@ -2074,6 +2194,7 @@ impl Client { self.inner.router.ensure_started( &self.inner.notification_tx, &self.inner.request_rx, + self.inner.extension_launch_provider.clone(), self.inner.llm_inference.get().cloned(), self.inner.on_github_telemetry.clone(), self.inner.github_token_registry.clone(), @@ -2293,6 +2414,7 @@ impl Client { self.inner.router.ensure_started( &self.inner.notification_tx, &self.inner.request_rx, + self.inner.extension_launch_provider.clone(), self.inner.llm_inference.get().cloned(), self.inner.on_github_telemetry.clone(), self.inner.github_token_registry.clone(), @@ -2466,6 +2588,7 @@ impl Client { let pid = self.pid(); info!(pid = ?pid, "stopping CLI process"); let mut errors: Vec = Vec::new(); + self.inner.extension_launch_provider.clear(); // Snapshot the registered session IDs without holding the router // lock across the destroy RPCs. @@ -2551,12 +2674,12 @@ impl Client { } } - // The runtime.shutdown RPC above already asked the runtime to clean up; - // closing here tears down the transport. + // Provider registration is scoped to the connection. Closing the + // transport unregisters it and prevents stale callbacks after stop. + self.inner.rpc.force_close(); #[cfg(feature = "bundled-in-process")] { if let Some(host) = self.inner.ffi_host.lock().take() { - self.inner.rpc.force_close(); host.close(); } } @@ -2601,6 +2724,7 @@ impl Client { pub fn force_stop(&self) { let pid = self.pid(); info!(pid = ?pid, "force-stopping CLI process"); + self.inner.extension_launch_provider.clear(); if let Some(mut child) = self.inner.child.lock().take() && let Err(e) = child.start_kill() { @@ -3325,6 +3449,7 @@ mod tests { Some(child), temp.path().to_path_buf(), None, + None, false, false, None, @@ -3433,6 +3558,9 @@ mod tests { session_fs_configured: false, session_fs_sqlite_declared: false, llm_inference: OnceLock::new(), + extension_launch_provider: Arc::new( + extension_launch_provider::ExtensionLaunchProviderDispatcher::new(None), + ), on_github_telemetry: None, on_get_trace_context: None, effective_connection_token: None, diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs index 1c88283a27..d8b996a11a 100644 --- a/rust/src/resolve.rs +++ b/rust/src/resolve.rs @@ -5,9 +5,9 @@ //! 1. An explicit path supplied by the application via //! [`CliProgram::Path`](crate::CliProgram::Path). //! 2. The `COPILOT_CLI_PATH` environment variable. -//! 3. The bundled CLI embedded in this crate at build time (when the +//! 3. The bundled program embedded in this crate at build time (when the //! `bundled-cli` cargo feature is on, the default). -//! 4. The build-time-extracted CLI in the per-user cache (when +//! 4. The build-time-extracted program in the per-user cache (when //! `bundled-cli` is off). //! //! There is no PATH scanning and no walking of standard install locations. @@ -35,6 +35,7 @@ use crate::{Error, ErrorKind}; /// under it. pub(crate) fn copilot_binary_with_extract_dir( extract_dir: Option<&Path>, + use_runtime_wrapper: bool, ) -> Result { if let Ok(value) = env::var("COPILOT_CLI_PATH") { let candidate = PathBuf::from(&value); @@ -49,11 +50,21 @@ pub(crate) fn copilot_binary_with_extract_dir( #[cfg(feature = "bundled-cli")] { - let bundled = match extract_dir { - Some(dir) => crate::embeddedcli::install_at(dir), - None => crate::embeddedcli::path(), + let bundled = if use_runtime_wrapper { + match extract_dir { + Some(dir) => crate::embeddedcli::install_runtime_at(dir), + None => crate::embeddedcli::runtime_path(), + } + } else { + match extract_dir { + Some(dir) => crate::embeddedcli::install_at(dir), + None => crate::embeddedcli::path(), + } }; if let Some(path) = bundled { + if use_runtime_wrapper { + validate_runtime_pair(&path)?; + } return Ok(path); } } @@ -61,16 +72,21 @@ pub(crate) fn copilot_binary_with_extract_dir( #[cfg(not(feature = "bundled-cli"))] { let _ = extract_dir; - if let Some(path) = extracted_cli_path() { - return Ok(path); + if let Some(program) = extracted_program(use_runtime_wrapper) { + return Ok(program); } } + let binary_name = if use_runtime_wrapper { + runtime_binary_name() + } else { + cli_binary_name() + }; Err(ErrorKind::BinaryNotFound { - name: "copilot".into(), + name: binary_name.into(), hint: Some( "the Copilot CLI is not bundled in this build of github-copilot-sdk and \ - COPILOT_CLI_PATH is not set. Either keep the default `bundled-cli` cargo \ + no applicable path override is set. Either keep the default `bundled-cli` cargo \ feature enabled, set COPILOT_CLI_PATH, or supply an explicit path via \ `CliProgram::Path(...)` on `ClientOptions::program`." .into(), @@ -79,7 +95,7 @@ pub(crate) fn copilot_binary_with_extract_dir( .into()) } -/// Path to the CLI extracted into the per-user cache by `build.rs` when +/// Path to the program extracted into the per-user cache by `build.rs` when /// `bundled-cli` is disabled. Returns `None` if the cached file is missing /// (e.g. the user deleted the cache after building, or built with /// `COPILOT_SKIP_CLI_DOWNLOAD`). @@ -93,14 +109,8 @@ pub(crate) fn copilot_binary_with_extract_dir( /// `$HOME` / `$LOCALAPPDATA` into the artifact, breaks sccache across /// machines, and prevents copying `target/` between hosts. #[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] -fn extracted_cli_path() -> Option { +fn extracted_program(use_runtime_wrapper: bool) -> Option { let version = env!("COPILOT_SDK_CLI_VERSION"); - let binary = if cfg!(windows) { - "copilot.exe" - } else { - "copilot" - }; - let dir = match env::var_os("COPILOT_CLI_EXTRACT_DIR") { Some(custom) => PathBuf::from(custom), None => dirs::cache_dir() @@ -110,8 +120,16 @@ fn extracted_cli_path() -> Option { .join(sanitize_version(version)), }; - let path = dir.join(binary); - if path.is_file() { + let path = dir.join(if use_runtime_wrapper { + runtime_binary_name() + } else { + cli_binary_name() + }); + if use_runtime_wrapper { + if validate_runtime_pair(&path).is_ok() { + return Some(path); + } + } else if path.is_file() { return Some(path); } warn!( @@ -125,10 +143,83 @@ fn extracted_cli_path() -> Option { /// build opted out via `COPILOT_SKIP_CLI_DOWNLOAD`. In both cases there's /// no binary to look up, so the resolver returns `None` immediately. #[cfg(all(not(feature = "bundled-cli"), not(has_extracted_cli)))] -fn extracted_cli_path() -> Option { +fn extracted_program(_use_runtime_wrapper: bool) -> Option { None } +fn validate_runtime_pair(wrapper: &Path) -> Result<(), Error> { + let wrapper_valid = wrapper + .metadata() + .map(|metadata| metadata.is_file() && metadata.len() > 0) + .unwrap_or(false); + let runtime_node = wrapper + .parent() + .map(|parent| parent.join("runtime.node")) + .unwrap_or_else(|| PathBuf::from("runtime.node")); + let runtime_valid = runtime_node + .metadata() + .map(|metadata| metadata.is_file() && metadata.len() > 0) + .unwrap_or(false); + if wrapper_valid && runtime_valid { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let metadata = wrapper.metadata().map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "failed to inspect Copilot runtime wrapper permissions at '{}': {e}", + wrapper.display() + ), + ) + })?; + if metadata.permissions().mode() & 0o111 == 0 { + let mut permissions = metadata.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + std::fs::set_permissions(wrapper, permissions).map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "failed to make Copilot runtime wrapper executable at '{}': {e}", + wrapper.display() + ), + ) + })?; + } + } + return Ok(()); + } + let detail = format!( + "The runtime wrapper and its adjacent runtime.node must both be non-empty files; checked '{}' and '{}'", + wrapper.display(), + runtime_node.display() + ); + Err(Error::with_message( + ErrorKind::BinaryNotFound { + name: runtime_binary_name().into(), + hint: Some(detail.clone()), + }, + detail, + )) +} + +fn cli_binary_name() -> &'static str { + if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + } +} + +fn runtime_binary_name() -> &'static str { + if cfg!(windows) { + "copilot-runtime.exe" + } else { + "copilot-runtime" + } +} + /// Replace characters outside `[a-zA-Z0-9._-]` with `_`. Kept in sync /// with `build.rs::sanitize_version` and `embeddedcli::sanitize_version` /// so all three resolve to the same cache directory for any given @@ -143,3 +234,29 @@ fn sanitize_version(version: &str) -> String { }) .collect() } + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::validate_runtime_pair; + + #[test] + fn runtime_pair_requires_adjacent_nonempty_runtime_node() { + let dir = tempdir().expect("temp dir"); + let wrapper = dir.path().join(if cfg!(windows) { + "copilot-runtime.exe" + } else { + "copilot-runtime" + }); + fs::write(&wrapper, b"wrapper").expect("write wrapper"); + + let error = validate_runtime_pair(&wrapper).expect_err("runtime.node is required"); + assert!(error.to_string().contains("runtime.node")); + + fs::write(dir.path().join("runtime.node"), b"runtime").expect("write runtime.node"); + validate_runtime_pair(&wrapper).expect("complete pair is valid"); + } +} diff --git a/rust/src/router.rs b/rust/src/router.rs index 1dec9d16f3..4815d0e1c5 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -85,6 +85,9 @@ impl SessionRouter { &self, notification_tx: &broadcast::Sender, request_rx: &Mutex>>, + extension_launch_provider: Arc< + crate::extension_launch_provider::ExtensionLaunchProviderDispatcher, + >, llm_inference: Option>, github_telemetry: Option, github_token_registry: Arc, @@ -182,6 +185,10 @@ impl SessionRouter { let sessions = self.sessions.clone(); tokio::spawn(async move { while let Some(request) = rx.recv().await { + if request.method == crate::extension_launch_provider::RESOLVE_METHOD { + extension_launch_provider.dispatch(request).await; + continue; + } if request.method == "gitHubToken.getToken" { github_token_registry.dispatch(request).await; continue; diff --git a/rust/src/startup_timings.rs b/rust/src/startup_timings.rs index 7938a462b7..7784a6ab3b 100644 --- a/rust/src/startup_timings.rs +++ b/rust/src/startup_timings.rs @@ -38,7 +38,7 @@ use std::time::Duration; #[non_exhaustive] pub struct StartupTimings { /// Time spent in `resolve::copilot_binary_with_extract_dir` locating (and, - /// for a bundled CLI, extracting) the copilot binary. `None` when the + /// for bundled artifacts, extracting) the Copilot program. `None` when the /// caller passes an explicit [`CliProgram::Path`](crate::CliProgram::Path). pub program_resolve_ms: Option, /// Time spent spawning the CLI subprocess (`command.spawn()`). `None` for diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index 9e4927e676..75773a0c0d 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -10,7 +10,10 @@ use std::path::PathBuf; use github_copilot_sdk::{ CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli, + install_bundled_runtime, }; +#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +use github_copilot_sdk::{SessionConfig, Transport}; use serial_test::serial; fn unset_env(key: &str) { @@ -95,7 +98,7 @@ async fn stale_env_override_falls_through() { } } -/// With `bundled-cli` off, `build.rs` extracts the binary into the +/// With `bundled-cli` off, `build.rs` extracts the runtime wrapper into the /// per-user cache and the runtime resolver recomputes its location from /// `COPILOT_SDK_CLI_VERSION` + the OS-derived binary name. This test /// mirrors that convention and asserts the file is on disk where the @@ -105,9 +108,9 @@ async fn stale_env_override_falls_through() { fn extracted_binary_present_at_conventional_path() { let version = env!("COPILOT_SDK_CLI_VERSION"); let binary = if cfg!(windows) { - "copilot.exe" + "copilot-runtime.exe" } else { - "copilot" + "copilot-runtime" }; let sanitized = sanitize_version_for_test(version); let path = dirs::cache_dir() @@ -158,21 +161,19 @@ async fn unbundled_resolver_finds_extracted_binary() { /// With `bundled-cli` off, `COPILOT_CLI_EXTRACT_DIR` set at runtime /// redirects the resolver to look directly under the named directory /// (no per-version subdir, matching the build-time write semantics). -/// We place a fake `copilot[.exe]` there and assert the resolver picks -/// it up — failing here means the build-time / runtime convention has -/// drifted. #[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] #[tokio::test(flavor = "current_thread")] #[serial(copilot_cli_path)] async fn extract_dir_runtime_override_is_honored() { let tmp = tempfile::tempdir().expect("create tempdir"); let binary = if cfg!(windows) { - "copilot.exe" + "copilot-runtime.exe" } else { - "copilot" + "copilot-runtime" }; let fake = tmp.path().join(binary); - std::fs::write(&fake, b"").expect("write fake binary"); + std::fs::write(&fake, b"runtime").expect("write fake binary"); + std::fs::write(tmp.path().join("runtime.node"), b"runtime").expect("write runtime.node"); unset_env("COPILOT_CLI_PATH"); set_env( @@ -265,6 +266,14 @@ fn install_bundled_cli_returns_extracted_path() { "install_bundled_cli returned a path that is not a file: {}", first.display() ); + assert_eq!( + first.file_name().and_then(|name| name.to_str()), + Some(if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }) + ); let second = install_bundled_cli().expect("second call should also succeed"); assert_eq!( @@ -293,30 +302,6 @@ fn install_bundled_cli_returns_extracted_path() { } } -/// `install_bundled_cli` returns the same path the runtime resolver -/// hands to `Client::start` for `CliProgram::Resolve` with no -/// `COPILOT_CLI_PATH` override. Observed indirectly: the binary the -/// public API points at must exist, and `Client::start` must not -/// report `BinaryNotFound` under the same env conditions. -#[cfg(all(feature = "bundled-cli", has_bundled_cli))] -#[tokio::test(flavor = "current_thread")] -#[serial(copilot_cli_path)] -async fn install_bundled_cli_matches_resolver() { - unset_env("COPILOT_CLI_PATH"); - unset_env("COPILOT_CLI_EXTRACT_DIR"); - - let direct = install_bundled_cli().expect("bundled CLI should install"); - assert!(direct.is_file()); - - let opts = ClientOptions::default().with_program(CliProgram::Resolve); - if let Err(e) = Client::start(opts).await { - assert!( - !matches!(e.kind(), ErrorKind::BinaryNotFound { .. }), - "resolver returned BinaryNotFound while install_bundled_cli succeeded: {e}" - ); - } -} - /// With `bundled-cli` off (or the target unsupported), the public API /// reports no bundled CLI and does not fall back to the /// build-time-extracted dev-cache path that `CliProgram::Resolve` uses. @@ -329,3 +314,97 @@ fn install_bundled_cli_is_none_without_embed() { "install_bundled_cli must not fall back to the dev-cache path" ); } + +#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[test] +fn install_bundled_runtime_returns_wrapper_bundle() { + let first = install_bundled_runtime().expect("bundled runtime should install"); + assert_eq!( + first.file_name().and_then(|name| name.to_str()), + Some(if cfg!(windows) { + "copilot-runtime.exe" + } else { + "copilot-runtime" + }) + ); + let runtime_node = first + .parent() + .expect("install directory") + .join("runtime.node"); + assert!( + runtime_node.is_file(), + "runtime.node was not installed: {}", + runtime_node.display() + ); + let second = install_bundled_runtime().expect("second call should also succeed"); + assert_eq!(first, second); +} + +#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[tokio::test(flavor = "current_thread")] +#[serial(copilot_cli_path)] +async fn bundled_runtime_clean_extract_starts_without_cli_host() { + let temp = tempfile::tempdir().expect("create tempdir"); + let extract_dir = temp.path().join("runtime"); + let empty_path = temp.path().join("empty-path"); + let working_dir = temp.path().join("work"); + std::fs::create_dir(&empty_path).expect("create empty PATH directory"); + std::fs::create_dir(&working_dir).expect("create working directory"); + assert!(!extract_dir.exists()); + + let options = ClientOptions::new() + .with_bundled_cli_extract_dir(&extract_dir) + .with_cwd(&working_dir) + .with_env([("PATH", empty_path.as_os_str())]) + .with_env_remove([ + "COPILOT_RUNTIME_HOST_COMMAND", + "COPILOT_CLI_PATH", + "COPILOT_RUNTIME_PROVIDER_LIB", + ]) + .with_transport(Transport::Stdio) + .with_use_logged_in_user(false); + let client = Client::start(options) + .await + .expect("start bundled runtime from clean extraction"); + let response = client + .ping(Some("hostless runtime")) + .await + .expect("ping bundled runtime"); + assert_eq!(response.message, "pong: hostless runtime"); + + let session = client + .create_session(SessionConfig::default()) + .await + .expect("create session"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop bundled runtime"); + + assert!(extract_dir.join("runtime.node").is_file()); + assert!( + extract_dir + .join(if cfg!(windows) { + "copilot-runtime.exe" + } else { + "copilot-runtime" + }) + .is_file() + ); + assert!( + !extract_dir + .join(if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }) + .exists() + ); +} + +#[cfg(not(all(feature = "bundled-cli", has_bundled_cli)))] +#[test] +fn install_bundled_runtime_is_none_without_embed() { + assert!( + install_bundled_runtime().is_none(), + "install_bundled_runtime must not fall back to the dev-cache path" + ); +} diff --git a/rust/tests/e2e/rewind.rs b/rust/tests/e2e/rewind.rs index c998f96df6..44d6a39404 100644 --- a/rust/tests/e2e/rewind.rs +++ b/rust/tests/e2e/rewind.rs @@ -9,15 +9,12 @@ use github_copilot_sdk::rpc::{ use super::support::assistant_message_content; const FILE_NAME: &str = "rewind-sdk.txt"; +const ORIGINAL_FILE_CONTENT: &str = "Original rewind content"; +const PREPARED_FILE_CONTENT: &str = "Prepared rewind content"; const FILE_CONTENT: &str = "SDK rewind content"; #[tokio::test] async fn should_restore_tracked_file_and_conversation() { - // TODO(cli-1.0.81): Re-enable when Windows file-change tracking records built-in create tool writes. - if cfg!(windows) { - return; - } - super::support::with_shared_e2e_context( &E2E, "rewind", @@ -26,6 +23,7 @@ async fn should_restore_tracked_file_and_conversation() { Box::pin(async move { ctx.set_default_copilot_user(); let file_path = ctx.work_dir().join(FILE_NAME); + std::fs::write(&file_path, ORIGINAL_FILE_CONTENT).expect("write original file"); let client = ctx.start_client().await; let session = client .create_session( @@ -36,11 +34,26 @@ async fn should_restore_tracked_file_and_conversation() { .await .expect("create session"); + let ready = session + .send_and_wait(format!( + "Use the edit tool to replace the exact contents of {FILE_NAME} from \ + {ORIGINAL_FILE_CONTENT} to {PREPARED_FILE_CONTENT}. After the tool \ + succeeds, reply with exactly SDK_REWIND_READY." + )) + .await + .expect("send readiness turn") + .expect("readiness response"); + assert_eq!(assistant_message_content(&ready), "SDK_REWIND_READY"); + assert_eq!( + std::fs::read_to_string(&file_path).expect("read prepared file"), + PREPARED_FILE_CONTENT + ); + let response = session .send_and_wait(format!( - "Use the create tool to create {FILE_NAME} containing exactly \ - {FILE_CONTENT}. After the tool succeeds, reply with exactly \ - SDK_REWIND_DONE." + "Use the edit tool to replace the exact contents of {FILE_NAME} from \ + {PREPARED_FILE_CONTENT} to {FILE_CONTENT}. After the tool succeeds, \ + reply with exactly SDK_REWIND_DONE." )) .await .expect("send rewind setup prompt") @@ -53,8 +66,9 @@ async fn should_restore_tracked_file_and_conversation() { let rewind_points = wait_for_rewind_points(&session).await; assert!(rewind_points.file_change_tracking_enabled); - assert_eq!(rewind_points.points.len(), 1); - let rewind_point = &rewind_points.points[0]; + assert_eq!(rewind_points.points.len(), 2); + let rewind_point = &rewind_points.points[1]; + assert!(rewind_point.turn_changed_files); assert!(rewind_point.can_restore_files); assert_eq!(rewind_point.file_count, 1); @@ -83,7 +97,10 @@ async fn should_restore_tracked_file_and_conversation() { assert!(rewind.events_removed.is_some_and(|count| count > 0)); assert_eq!(rewind.restored_files.len(), 1); assert_same_path(&file_path, Path::new(&rewind.restored_files[0])); - assert!(!file_path.exists()); + assert_eq!( + std::fs::read_to_string(&file_path).expect("read restored file"), + PREPARED_FILE_CONTENT + ); let events = session.get_events().await.expect("get events after rewind"); assert!(events.iter().all(|event| event.id != rewind_point.event_id)); @@ -108,10 +125,10 @@ async fn wait_for_rewind_points( .await .expect("list rewind points"); if result.unavailable_reason.is_none() - && result - .points - .first() - .is_some_and(|point| point.can_restore_files && point.file_count == 1) + && result.points.len() == 2 + && result.points[1].turn_changed_files + && result.points[1].can_restore_files + && result.points[1].file_count == 1 { return result; } diff --git a/rust/tests/e2e/rpc_session_state.rs b/rust/tests/e2e/rpc_session_state.rs index aa67312473..c723d10f5e 100644 --- a/rust/tests/e2e/rpc_session_state.rs +++ b/rust/tests/e2e/rpc_session_state.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use std::sync::Arc; +use async_trait::async_trait; use github_copilot_sdk::rpc::{ AuthInfoType, HistoryTruncateRequest, LspInitializeRequest, MetadataContextInfoRequest, MetadataRecomputeContextTokensRequest, MetadataRecordContextChangeRequest, @@ -16,8 +18,14 @@ use github_copilot_sdk::session_events::{ SessionTitleChangedData, SessionWorkspaceFileChangedData, ShutdownType, WorkspaceFileChangedOperation, }; +use github_copilot_sdk::tool::ToolHandler; +use github_copilot_sdk::{Error, Tool, ToolInvocation, ToolResult}; +use serde_json::json; +use tokio::sync::{Mutex, mpsc, oneshot}; -use super::support::{assistant_message_content, wait_for_condition, wait_for_event}; +use super::support::{ + assistant_message_content, recv_with_timeout, wait_for_condition, wait_for_event, +}; const MODEL_ID: &str = "claude-sonnet-4.5"; @@ -1111,8 +1119,28 @@ async fn should_report_processing_and_context_metadata() { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; + let (entered_tx, mut entered_rx) = mpsc::unbounded_channel(); + let (release_tx, release_rx) = oneshot::channel(); let session = client - .create_session(ctx.approve_all_session_config().with_model(MODEL_ID)) + .create_session( + ctx.approve_all_session_config() + .with_model(MODEL_ID) + .with_tools(vec![ + Tool::new("processing_barrier") + .with_description( + "Blocks until the processing state has been observed", + ) + .with_parameters(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .with_handler(Arc::new(ProcessingBarrierTool { + entered_tx, + release_rx: Mutex::new(Some(release_rx)), + })), + ]), + ) .await .expect("create session"); @@ -1126,19 +1154,20 @@ async fn should_report_processing_and_context_metadata() { .processing ); session - .send("Reply with exactly: RUST_CONTEXT_INFO") + .send("Use processing_barrier, then reply with exactly: RUST_CONTEXT_INFO") .await .expect("send"); - wait_for_condition("session processing started", || async { + recv_with_timeout(&mut entered_rx, "processing barrier entry").await; + assert!( session .rpc() .metadata() .is_processing() .await - .expect("processing poll") + .expect("processing during tool call") .processing - }) - .await; + ); + release_tx.send(()).expect("release processing barrier"); wait_for_condition("session processing completed", || async { !session .rpc() @@ -1180,6 +1209,26 @@ async fn should_report_processing_and_context_metadata() { .await; } +struct ProcessingBarrierTool { + entered_tx: mpsc::UnboundedSender<()>, + release_rx: Mutex>>, +} + +#[async_trait] +impl ToolHandler for ProcessingBarrierTool { + async fn call(&self, _invocation: ToolInvocation) -> Result { + let _ = self.entered_tx.send(()); + self.release_rx + .lock() + .await + .take() + .expect("processing barrier called once") + .await + .expect("processing barrier released"); + Ok(ToolResult::Text("PROCESSING_OBSERVED".to_string())) + } +} + fn expect_err_contains(result: Result, expected: &str) { let err = match result { Ok(_) => panic!("expected error containing {expected:?}"), diff --git a/rust/tests/e2e/session.rs b/rust/tests/e2e/session.rs index e2ca76c478..8c2463a5fa 100644 --- a/rust/tests/e2e/session.rs +++ b/rust/tests/e2e/session.rs @@ -501,8 +501,7 @@ async fn should_abort_a_session() { #[tokio::test] async fn should_resume_a_session_using_the_same_client() { - super::support::with_shared_e2e_context( - &E2E, + super::support::with_dedicated_e2e_context( "session", "should_resume_a_session_using_the_same_client", |ctx| { @@ -1752,4 +1751,4 @@ fn secret_number_tool() -> Tool { .with_handler(Arc::new(SecretNumberTool)) } static E2E: super::support::SharedE2eGroup = - super::support::SharedE2eGroup::standard("session", 30); + super::support::SharedE2eGroup::standard("session", 29); diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 76f9a21c8f..8a4161efef 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -510,12 +510,8 @@ impl E2eContext { .expect("start E2E client") } - /// Start a client that hosts the runtime in-process over FFI - /// ([`Transport::InProcess`]). Unlike the stdio harness, the CLI - /// entrypoint is passed as the program directly (the FFI host builds the - /// `node --embedded-host` argv itself and loads the sibling - /// runtime cdylib), so a `.js` entrypoint is not split into node + - /// prefix_args here. + /// Start a client that hosts the bundled runtime directly in-process over + /// FFI ([`Transport::InProcess`]). #[cfg_attr(not(feature = "bundled-in-process"), allow(dead_code))] pub async fn start_inprocess_client(&self) -> Client { let options = ClientOptions::new().with_transport(Transport::InProcess); @@ -1070,7 +1066,8 @@ impl InProcessEnvGuard { pairs.push(("COPILOT_SDK_AUTH_TOKEN".into(), "".into())); pairs.push(( "COPILOT_CLI_PATH".into(), - ctx.cli_path.clone().into_os_string(), + std::env::var_os("COPILOT_CLI_PATH") + .unwrap_or_else(|| ctx.cli_path.clone().into_os_string()), )); // Some tests opt into gated runtime APIs via per-client `options.env`, which the // in-process transport does not pass to the shared native runtime (see issue #1934). diff --git a/rust/tests/extension_launch_provider_runtime_test.rs b/rust/tests/extension_launch_provider_runtime_test.rs new file mode 100644 index 0000000000..f08d6af7f0 --- /dev/null +++ b/rust/tests/extension_launch_provider_runtime_test.rs @@ -0,0 +1,321 @@ +//! Real-boundary coverage for hostless extension discovery and lifecycle. +//! +//! Router tests can prove provider dispatch without exercising runtime session +//! setup, while an empty extension-list smoke test never requires the runtime +//! to install extension services. This test requires both boundaries and is +//! ignored until a real wrapper/runtime.node pair is supplied explicitly. + +#![cfg(unix)] +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use github_copilot_sdk::extension_launch_provider::{ + ExtensionLaunchProfile, ExtensionLaunchProvider, ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, +}; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::rpc::{ + ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, + ToolResult, ToolResultType, ToolsExecuteRequest, +}; +use github_copilot_sdk::{CliProgram, Client, ClientOptions, SessionConfig, Transport}; +use serde_json::{Value, json}; +use tokio::sync::mpsc; +use tokio::time::{sleep, timeout}; + +const TEST_TIMEOUT: Duration = Duration::from_secs(15); +const RUNTIME_PATH_ENV: &str = "COPILOT_RUNTIME_E2E_PATH"; + +struct RecordingProvider { + executable: PathBuf, + state_path: PathBuf, + requests: mpsc::UnboundedSender, +} + +#[async_trait] +impl ExtensionLaunchProvider for RecordingProvider { + async fn resolve( + &self, + request: ExtensionLaunchProviderResolveRequest, + ) -> github_copilot_sdk::Result { + self.requests.send(request.clone()).unwrap(); + Ok(ExtensionLaunchProviderResolveResult { + launch: Some(ExtensionLaunchProfile { + executable: self.executable.to_string_lossy().into_owned(), + args: Vec::new(), + env: HashMap::from([ + ("EXTENSION_PATH".to_string(), request.module_path.clone()), + ( + "FIXTURE_STATE_PATH".to_string(), + self.state_path.to_string_lossy().into_owned(), + ), + ("COPILOT_AUTO_UPDATE".to_string(), "false".to_string()), + ( + "COPILOT_SDK_PATH".to_string(), + "provider-must-not-win".to_string(), + ), + ( + "SESSION_ID".to_string(), + "provider-must-not-win".to_string(), + ), + ( + "COPILOT_EXTENSION_PARENT_PID".to_string(), + "provider-must-not-win".to_string(), + ), + ]), + }), + }) + } +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires COPILOT_RUNTIME_E2E_PATH to name a real wrapper/runtime.node pair"] +async fn real_wrapper_installs_and_runs_hostless_extensions() { + let runtime_path = std::env::var_os(RUNTIME_PATH_ENV) + .map(PathBuf::from) + .expect("COPILOT_RUNTIME_E2E_PATH must name a copilot-runtime executable"); + assert_runtime_pair(&runtime_path); + + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let extension_dir = workspace + .path() + .join(".github") + .join("extensions") + .join("lifecycle"); + std::fs::create_dir_all(&extension_dir).unwrap(); + let module_path = extension_dir.join("extension.mjs"); + std::fs::write(&module_path, "export default {};\n").unwrap(); + let module_path = std::fs::canonicalize(module_path).unwrap(); + let sdk_path = home.path().join("extension-sdk"); + std::fs::create_dir_all(&sdk_path).unwrap(); + let sdk_path = std::fs::canonicalize(sdk_path).unwrap(); + let state_path = home.path().join("extension-state.jsonl"); + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + + let options = ClientOptions::new() + .with_program(CliProgram::Path(runtime_path)) + .with_transport(Transport::Stdio) + .with_cwd(workspace.path()) + .with_base_directory(home.path()) + .with_use_logged_in_user(false) + .with_env_remove(["COPILOT_CLI_DIST_DIR"]) + .with_extension_launch_provider(RecordingProvider { + executable: PathBuf::from(env!("CARGO_BIN_EXE_copilot-extension-test-fixture")), + state_path: state_path.clone(), + requests: request_tx, + }); + let client = Client::start(options).await.unwrap(); + let session = client + .create_session( + SessionConfig::default() + .with_request_extensions(true) + .with_extension_sdk_path(sdk_path.to_string_lossy()) + .with_permission_handler(Arc::new(ApproveAllHandler)), + ) + .await + .unwrap(); + + let first_request = recv_request(&mut request_rx).await; + assert_request(&first_request, &module_path); + let first_start = wait_for_starts(&state_path, 1).await.remove(0); + assert_runtime_owned_environment(&first_start, session.id(), &sdk_path, &module_path); + let first_pid = json_pid(&first_start); + let wrapper_pid = json_parent_pid(&first_start); + assert_eq!(direct_child_pids(wrapper_pid), vec![first_pid]); + + let listed = session.rpc().extensions().list().await.unwrap(); + assert_eq!(listed.extensions.len(), 1); + assert_eq!(listed.extensions[0].id, "project:lifecycle"); + assert_eq!(listed.extensions[0].status, ExtensionStatus::Running); + + let result = session + .rpc() + .tools() + .execute(ToolsExecuteRequest { + arguments: json!({ "text": "sdk-boundary" }), + name: "fixture_echo".to_string(), + tool_call_id: Some("fixture-call".to_string()), + }) + .await + .unwrap(); + match result { + ToolResult::String(value) => assert_eq!(value, "echoed"), + ToolResult::ToolResultExpanded(result) => { + assert_eq!(result.text_result_for_llm, "echoed"); + assert_eq!(result.result_type, ToolResultType::Success); + } + } + wait_for_invocations(&state_path, 1).await; + + session + .rpc() + .extensions() + .disable(ExtensionsDisableRequest { + id: "project:lifecycle".to_string(), + }) + .await + .unwrap(); + wait_for_process_exit(first_pid).await; + let disabled = session.rpc().extensions().list().await.unwrap(); + assert_eq!(disabled.extensions[0].status, ExtensionStatus::Disabled); + + session + .rpc() + .extensions() + .enable(ExtensionsEnableRequest { + id: "project:lifecycle".to_string(), + }) + .await + .unwrap(); + let second_request = recv_request(&mut request_rx).await; + assert_request(&second_request, &module_path); + let starts = wait_for_starts(&state_path, 2).await; + let second_pid = json_pid(&starts[1]); + assert_ne!(second_pid, first_pid); + assert!(process_exists(second_pid)); + assert_eq!(json_parent_pid(&starts[1]), wrapper_pid); + assert_eq!(direct_child_pids(wrapper_pid), vec![second_pid]); + let enabled = session.rpc().extensions().list().await.unwrap(); + assert_eq!(enabled.extensions[0].status, ExtensionStatus::Running); + + client.stop().await.unwrap(); + wait_for_process_exit(second_pid).await; + wait_for_process_exit(wrapper_pid).await; + assert!(request_rx.try_recv().is_err()); +} + +fn assert_runtime_pair(runtime_path: &Path) { + assert!(runtime_path.is_file(), "missing {}", runtime_path.display()); + let runtime_node = runtime_path.parent().unwrap().join("runtime.node"); + assert!(runtime_node.is_file(), "missing {}", runtime_node.display()); +} + +async fn recv_request( + requests: &mut mpsc::UnboundedReceiver, +) -> ExtensionLaunchProviderResolveRequest { + timeout(TEST_TIMEOUT, requests.recv()) + .await + .expect( + "runtime never invoked extensionLaunchProvider.resolve; hostless extension services \ + may not be installed", + ) + .expect("provider request channel closed") +} + +fn assert_request(request: &ExtensionLaunchProviderResolveRequest, module_path: &Path) { + assert_eq!(request.id, "project:lifecycle"); + assert_eq!(request.name, "lifecycle"); + assert_eq!(request.source, ExtensionSource::Project); + assert_eq!(Path::new(&request.module_path), module_path); +} + +fn read_state(path: &Path) -> Vec { + let Ok(contents) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + contents + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +async fn wait_for_starts(path: &Path, count: usize) -> Vec { + wait_for_state(path, "start", count).await +} + +async fn wait_for_invocations(path: &Path, count: usize) -> Vec { + wait_for_state(path, "invoke", count).await +} + +async fn wait_for_state(path: &Path, kind: &str, count: usize) -> Vec { + let deadline = Instant::now() + TEST_TIMEOUT; + loop { + let matching: Vec<_> = read_state(path) + .into_iter() + .filter(|entry| entry["kind"] == kind) + .collect(); + if matching.len() >= count { + return matching; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {count} {kind} entries; state: {:?}", + read_state(path) + ); + sleep(Duration::from_millis(25)).await; + } +} + +fn assert_runtime_owned_environment( + start: &Value, + session_id: &str, + sdk_path: &Path, + module_path: &Path, +) { + assert_eq!(start["sessionId"], session_id); + assert_eq!( + start["sdkPath"].as_str(), + Some(sdk_path.to_string_lossy().as_ref()) + ); + assert_eq!( + start["extensionPath"].as_str(), + Some(module_path.to_string_lossy().as_ref()) + ); + assert_eq!(start["autoUpdate"], "false"); + assert!(start["cliDistDir"].is_null()); + let parent_pid = start["parentPid"].as_str().unwrap(); + assert_ne!(parent_pid, "provider-must-not-win"); + assert!(parent_pid.parse::().is_ok()); +} + +fn json_pid(value: &Value) -> u32 { + value["pid"].as_u64().unwrap() as u32 +} + +fn json_parent_pid(value: &Value) -> u32 { + value["parentPid"].as_str().unwrap().parse().unwrap() +} + +fn direct_child_pids(parent_pid: u32) -> Vec { + let output = Command::new("ps") + .args(["-axo", "pid=,ppid="]) + .output() + .expect("list processes"); + assert!(output.status.success(), "ps failed: {output:?}"); + let mut children: Vec<_> = String::from_utf8(output.stdout) + .unwrap() + .lines() + .filter_map(|line| { + let mut columns = line.split_whitespace(); + let pid = columns.next()?.parse::().ok()?; + let ppid = columns.next()?.parse::().ok()?; + (ppid == parent_pid).then_some(pid) + }) + .collect(); + children.sort_unstable(); + children +} + +fn process_exists(pid: u32) -> bool { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +async fn wait_for_process_exit(pid: u32) { + let deadline = Instant::now() + TEST_TIMEOUT; + while process_exists(pid) { + assert!(Instant::now() < deadline, "process {pid} did not exit"); + sleep(Duration::from_millis(25)).await; + } +} diff --git a/rust/tests/extension_launch_provider_test.rs b/rust/tests/extension_launch_provider_test.rs new file mode 100644 index 0000000000..b1a9592133 --- /dev/null +++ b/rust/tests/extension_launch_provider_test.rs @@ -0,0 +1,493 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use github_copilot_sdk::extension_launch_provider::{ + ExtensionLaunchProfile, ExtensionLaunchProvider, ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, +}; +use github_copilot_sdk::rpc::ExtensionSource; +use github_copilot_sdk::{CliProgram, Client, ClientOptions, Error, ErrorKind, Transport}; +use serde_json::{Value, json}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, duplex}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Notify, mpsc}; +use tokio::time::timeout; + +const TEST_TIMEOUT: Duration = Duration::from_secs(2); + +async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), value: &Value) { + let body = serde_json::to_vec(value).unwrap(); + let header = format!("Content-Length: {}\r\n\r\n", body.len()); + writer.write_all(header.as_bytes()).await.unwrap(); + writer.write_all(&body).await.unwrap(); + writer.flush().await.unwrap(); +} + +async fn read_framed(reader: &mut (impl AsyncRead + Unpin)) -> Option { + let mut header = String::new(); + loop { + let mut byte = [0u8; 1]; + match reader.read_exact(&mut byte).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return None, + Err(error) => panic!("failed to read frame header: {error}"), + } + header.push(byte[0] as char); + if header.ends_with("\r\n\r\n") { + break; + } + } + + let length = header + .trim() + .strip_prefix("Content-Length: ") + .unwrap() + .parse() + .unwrap(); + let mut body = vec![0; length]; + reader.read_exact(&mut body).await.unwrap(); + Some(serde_json::from_slice(&body).unwrap()) +} + +fn resolve_params() -> Value { + json!({ + "id": "project:legacy-extension", + "modulePath": "/extensions/legacy/index.js", + "name": "Legacy extension", + "source": "project" + }) +} + +fn app_launch_result(executable: &str) -> ExtensionLaunchProviderResolveResult { + ExtensionLaunchProviderResolveResult { + launch: Some(ExtensionLaunchProfile { + executable: executable.to_string(), + args: vec!["/app/preloads/extension_bootstrap.mjs".to_string()], + env: HashMap::from([ + ("COPILOT_AUTO_UPDATE".to_string(), "false".to_string()), + ( + "EXTENSION_PATH".to_string(), + "/extensions/legacy/index.js".to_string(), + ), + ]), + }), + } +} + +struct AppProvider { + executable: String, + observed: Option>, + release: Option>, + calls: Arc, +} + +#[async_trait] +impl ExtensionLaunchProvider for AppProvider { + async fn resolve( + &self, + request: ExtensionLaunchProviderResolveRequest, + ) -> github_copilot_sdk::Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(observed) = &self.observed { + observed.send(request).unwrap(); + } + if let Some(release) = &self.release { + release.notified().await; + } + Ok(app_launch_result(&self.executable)) + } +} + +struct FailingProvider; + +#[async_trait] +impl ExtensionLaunchProvider for FailingProvider { + async fn resolve( + &self, + _request: ExtensionLaunchProviderResolveRequest, + ) -> github_copilot_sdk::Result { + Err(Error::with_message( + ErrorKind::InvalidConfig, + "extension profile lookup failed", + )) + } +} + +#[test] +fn launch_profile_request_and_result_serialize_exactly() { + let request: ExtensionLaunchProviderResolveRequest = + serde_json::from_value(resolve_params()).unwrap(); + assert_eq!(request.id, "project:legacy-extension"); + assert_eq!(request.module_path, "/extensions/legacy/index.js"); + assert_eq!(request.name, "Legacy extension"); + assert_eq!(request.source, ExtensionSource::Project); + assert_eq!(serde_json::to_value(request).unwrap(), resolve_params()); + + let result = app_launch_result("/app/copilot"); + assert_eq!( + serde_json::to_value(&result).unwrap(), + json!({ + "launch": { + "executable": "/app/copilot", + "args": ["/app/preloads/extension_bootstrap.mjs"], + "env": { + "COPILOT_AUTO_UPDATE": "false", + "EXTENSION_PATH": "/extensions/legacy/index.js" + } + } + }) + ); + + let round_trip: ExtensionLaunchProviderResolveResult = + serde_json::from_value(serde_json::to_value(result).unwrap()).unwrap(); + let launch = round_trip.launch.unwrap(); + assert_eq!(launch.executable, "/app/copilot"); + assert_eq!(launch.args, vec!["/app/preloads/extension_bootstrap.mjs"]); + assert_eq!( + launch.env, + HashMap::from([ + ("COPILOT_AUTO_UPDATE".to_string(), "false".to_string()), + ( + "EXTENSION_PATH".to_string(), + "/extensions/legacy/index.js".to_string() + ) + ]) + ); +} + +#[tokio::test] +async fn configured_async_provider_dispatches_without_a_session() { + let (client_write, mut server_read) = duplex(8192); + let (mut server_write, client_read) = duplex(8192); + let temp = tempfile::tempdir().unwrap(); + let (observed_tx, mut observed_rx) = mpsc::unbounded_channel(); + let release = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + let client = Client::from_streams_with_extension_launch_provider( + client_read, + client_write, + temp.path().to_path_buf(), + Arc::new(AppProvider { + executable: "/app/copilot".to_string(), + observed: Some(observed_tx), + release: Some(release.clone()), + calls: calls.clone(), + }), + ) + .unwrap(); + client.start_router_for_test(); + + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "extensionLaunchProvider.resolve", + "params": resolve_params() + }), + ) + .await; + + let observed = timeout(TEST_TIMEOUT, observed_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(observed.id, "project:legacy-extension"); + assert!( + timeout(Duration::from_millis(25), read_framed(&mut server_read)) + .await + .is_err() + ); + + release.notify_one(); + let response = timeout(TEST_TIMEOUT, read_framed(&mut server_read)) + .await + .unwrap() + .unwrap(); + assert_eq!(response["id"], 41); + assert_eq!( + response["result"], + serde_json::to_value(app_launch_result("/app/copilot")).unwrap() + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn missing_provider_returns_client_global_handler_error() { + let (client_write, mut server_read) = duplex(8192); + let (mut server_write, client_read) = duplex(8192); + let temp = tempfile::tempdir().unwrap(); + let client = + Client::from_streams(client_read, client_write, temp.path().to_path_buf()).unwrap(); + client.start_router_for_test(); + + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "extensionLaunchProvider.resolve", + "params": resolve_params() + }), + ) + .await; + + let response = timeout(TEST_TIMEOUT, read_framed(&mut server_read)) + .await + .unwrap() + .unwrap(); + assert_eq!(response["id"], 42); + assert_eq!(response["error"]["code"], -32603); + assert_eq!( + response["error"]["message"], + "No extensionLaunchProvider client-global handler registered" + ); +} + +#[tokio::test] +async fn provider_error_is_returned_as_json_rpc_error() { + let (client_write, mut server_read) = duplex(8192); + let (mut server_write, client_read) = duplex(8192); + let temp = tempfile::tempdir().unwrap(); + let client = Client::from_streams_with_extension_launch_provider( + client_read, + client_write, + temp.path().to_path_buf(), + Arc::new(FailingProvider), + ) + .unwrap(); + client.start_router_for_test(); + + write_framed( + &mut server_write, + &json!({ + "jsonrpc": "2.0", + "id": 43, + "method": "extensionLaunchProvider.resolve", + "params": resolve_params() + }), + ) + .await; + + let response = timeout(TEST_TIMEOUT, read_framed(&mut server_read)) + .await + .unwrap() + .unwrap(); + assert_eq!(response["id"], 43); + assert_eq!(response["error"]["code"], -32603); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("extension profile lookup failed") + ); +} + +async fn respond_to_connect( + reader: &mut (impl AsyncRead + Unpin), + writer: &mut (impl AsyncWrite + Unpin), +) { + let request = read_framed(reader).await.unwrap(); + assert_eq!(request["method"], "connect"); + write_framed( + writer, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "ok": true, + "protocolVersion": 3, + "version": "test" + } + }), + ) + .await; +} + +async fn read_registration(reader: &mut (impl AsyncRead + Unpin)) -> Value { + let request = read_framed(reader).await.unwrap(); + assert_eq!(request["method"], "registerExtensionLaunchProvider"); + assert_eq!(request["params"], json!({})); + request +} + +fn external_options(port: u16, provider: AppProvider) -> ClientOptions { + ClientOptions::new() + .with_program(CliProgram::Path("unused-for-external-transport".into())) + .with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port, + connection_token: None, + }) + .with_extension_launch_provider(provider) +} + +#[tokio::test] +async fn registration_precedes_session_work_and_routes_during_start() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let calls = Arc::new(AtomicUsize::new(0)); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (mut reader, mut writer) = stream.into_split(); + respond_to_connect(&mut reader, &mut writer).await; + let registration = read_registration(&mut reader).await; + + write_framed( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 501, + "method": "extensionLaunchProvider.resolve", + "params": resolve_params() + }), + ) + .await; + let callback_response = read_framed(&mut reader).await.unwrap(); + assert_eq!(callback_response["id"], 501); + assert_eq!( + callback_response["result"], + serde_json::to_value(app_launch_result("/app/copilot")).unwrap() + ); + + write_framed( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": registration["id"], + "result": null + }), + ) + .await; + assert!( + timeout(Duration::from_millis(50), read_framed(&mut reader)) + .await + .is_err() + ); + }); + + let client = Client::start(external_options( + port, + AppProvider { + executable: "/app/copilot".to_string(), + observed: None, + release: None, + calls: calls.clone(), + }, + )) + .await + .unwrap(); + + timeout(TEST_TIMEOUT, server).await.unwrap().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + client.stop().await.unwrap(); +} + +async fn serve_registered_client(stream: TcpStream, request_id: u64, expected_executable: &str) { + let (mut reader, mut writer) = stream.into_split(); + respond_to_connect(&mut reader, &mut writer).await; + let registration = read_registration(&mut reader).await; + write_framed( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": registration["id"], + "result": null + }), + ) + .await; + + write_framed( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "extensionLaunchProvider.resolve", + "params": resolve_params() + }), + ) + .await; + let response = read_framed(&mut reader).await.unwrap(); + assert_eq!(response["id"], request_id); + assert_eq!( + response["result"]["launch"]["executable"], + expected_executable + ); + assert_eq!(read_framed(&mut reader).await, None); +} + +#[tokio::test] +async fn shutdown_and_restart_do_not_reuse_or_duplicate_providers() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let first_calls = Arc::new(AtomicUsize::new(0)); + let second_calls = Arc::new(AtomicUsize::new(0)); + let (served_tx, mut served_rx) = mpsc::unbounded_channel(); + + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + serve_registered_client(first, 601, "/app/first-copilot").await; + served_tx.send(()).unwrap(); + + let (second, _) = listener.accept().await.unwrap(); + serve_registered_client(second, 602, "/app/second-copilot").await; + served_tx.send(()).unwrap(); + }); + + let first = Client::start(external_options( + port, + AppProvider { + executable: "/app/first-copilot".to_string(), + observed: None, + release: None, + calls: first_calls.clone(), + }, + )) + .await + .unwrap(); + timeout(TEST_TIMEOUT, async { + while first_calls.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + first.stop().await.unwrap(); + timeout(TEST_TIMEOUT, served_rx.recv()) + .await + .unwrap() + .unwrap(); + + let second = Client::start(external_options( + port, + AppProvider { + executable: "/app/second-copilot".to_string(), + observed: None, + release: None, + calls: second_calls.clone(), + }, + )) + .await + .unwrap(); + timeout(TEST_TIMEOUT, async { + while second_calls.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + second.stop().await.unwrap(); + timeout(TEST_TIMEOUT, served_rx.recv()) + .await + .unwrap() + .unwrap(); + timeout(TEST_TIMEOUT, server).await.unwrap().unwrap(); + + assert_eq!(first_calls.load(Ordering::SeqCst), 1); + assert_eq!(second_calls.load(Ordering::SeqCst), 1); +} diff --git a/rust/tests/fixtures/extension_fixture.rs b/rust/tests/fixtures/extension_fixture.rs new file mode 100644 index 0000000000..787c82503a --- /dev/null +++ b/rust/tests/fixtures/extension_fixture.rs @@ -0,0 +1,127 @@ +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, Write}; + +use serde_json::{Value, json}; + +fn main() { + let session_id = std::env::var("SESSION_ID").expect("SESSION_ID"); + record_state(json!({ + "kind": "start", + "pid": std::process::id(), + "sessionId": session_id, + "sdkPath": std::env::var("COPILOT_SDK_PATH").ok(), + "parentPid": std::env::var("COPILOT_EXTENSION_PARENT_PID").ok(), + "extensionPath": std::env::var("EXTENSION_PATH").ok(), + "autoUpdate": std::env::var("COPILOT_AUTO_UPDATE").ok(), + "cliDistDir": std::env::var("COPILOT_CLI_DIST_DIR").ok(), + })); + + let stdin = std::io::stdin(); + let mut reader = BufReader::new(stdin.lock()); + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + write_message( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session.resume", + "params": { + "sessionId": session_id, + "tools": [{ + "name": "fixture_echo", + "description": "Returns a deterministic fixture result", + "parameters": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"] + } + }], + "requestPermission": false, + "enableConfigDiscovery": false, + "disableResume": true, + "streaming": true + } + }), + ); + + let mut next_id = 2_u64; + while let Some(message) = read_message(&mut reader) { + if message.get("id") == Some(&json!(1)) { + assert!( + message.get("error").is_none(), + "extension resume failed: {message}" + ); + continue; + } + if message.pointer("/params/event/type") != Some(&json!("external_tool.requested")) { + continue; + } + let Some(request_id) = message + .pointer("/params/event/data/requestId") + .and_then(Value::as_str) + else { + continue; + }; + record_state(json!({ + "kind": "invoke", + "pid": std::process::id(), + "requestId": request_id, + })); + write_message( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": next_id, + "method": "session.tools.handlePendingToolCall", + "params": { + "sessionId": session_id, + "requestId": request_id, + "result": "echoed" + } + }), + ); + next_id += 1; + } +} + +fn record_state(value: Value) { + let path = std::env::var("FIXTURE_STATE_PATH").expect("FIXTURE_STATE_PATH"); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("open fixture state"); + writeln!(file, "{value}").expect("write fixture state"); +} + +fn write_message(writer: &mut impl Write, value: &Value) { + let body = serde_json::to_vec(value).expect("serialize fixture message"); + write!(writer, "Content-Length: {}\r\n\r\n", body.len()).expect("write fixture header"); + writer.write_all(&body).expect("write fixture body"); + writer.flush().expect("flush fixture message"); +} + +fn read_message(reader: &mut impl BufRead) -> Option { + let mut content_length = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).expect("read fixture header") == 0 { + return None; + } + if line == "\r\n" { + break; + } + if let Some(value) = line.strip_prefix("Content-Length:") { + content_length = Some( + value + .trim() + .parse::() + .expect("parse fixture content length"), + ); + } + } + let mut body = vec![0; content_length.expect("fixture content length")]; + reader.read_exact(&mut body).expect("read fixture body"); + Some(serde_json::from_slice(&body).expect("parse fixture message")) +} diff --git a/test/snapshots/rewind/should_restore_tracked_file_and_conversation.yaml b/test/snapshots/rewind/should_restore_tracked_file_and_conversation.yaml index 2ef3733e00..8a099ec46a 100644 --- a/test/snapshots/rewind/should_restore_tracked_file_and_conversation.yaml +++ b/test/snapshots/rewind/should_restore_tracked_file_and_conversation.yaml @@ -5,17 +5,33 @@ conversations: - role: system content: ${system} - role: user - content: Use the create tool to create rewind-sdk.txt containing exactly SDK rewind content. After the tool succeeds, - reply with exactly SDK_REWIND_DONE. + content: Use the edit tool to replace the exact contents of rewind-sdk.txt from Original rewind content to Prepared + rewind content. After the tool succeeds, reply with exactly SDK_REWIND_READY. - role: assistant tool_calls: - id: toolcall_0 type: function function: - name: create - arguments: '{"path":"${workdir}/rewind-sdk.txt","file_text":"SDK rewind content"}' + name: edit + arguments: '{"path":"${workdir}/rewind-sdk.txt","old_str":"Original rewind content","new_str":"Prepared rewind + content"}' - role: tool tool_call_id: toolcall_0 - content: Created file ${workdir}/rewind-sdk.txt with 18 characters + content: File ${workdir}/rewind-sdk.txt updated with changes. + - role: assistant + content: SDK_REWIND_READY + - role: user + content: Use the edit tool to replace the exact contents of rewind-sdk.txt from Prepared rewind content to SDK rewind + content. After the tool succeeds, reply with exactly SDK_REWIND_DONE. + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: edit + arguments: '{"path":"${workdir}/rewind-sdk.txt","old_str":"Prepared rewind content","new_str":"SDK rewind content"}' + - role: tool + tool_call_id: toolcall_1 + content: File ${workdir}/rewind-sdk.txt updated with changes. - role: assistant content: SDK_REWIND_DONE diff --git a/test/snapshots/rpc_session_state/should_report_processing_and_context_metadata.yaml b/test/snapshots/rpc_session_state/should_report_processing_and_context_metadata.yaml index 6760888d7b..d79fd22384 100644 --- a/test/snapshots/rpc_session_state/should_report_processing_and_context_metadata.yaml +++ b/test/snapshots/rpc_session_state/should_report_processing_and_context_metadata.yaml @@ -5,6 +5,16 @@ conversations: - role: system content: ${system} - role: user - content: "Reply with exactly: RUST_CONTEXT_INFO" + content: "Use processing_barrier, then reply with exactly: RUST_CONTEXT_INFO" + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: processing_barrier + arguments: "{}" + - role: tool + tool_call_id: toolcall_0 + content: PROCESSING_OBSERVED - role: assistant content: RUST_CONTEXT_INFO From 5a2a857bd711527cc7d7f0ef4bc545cc99e88d7f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 08:56:08 +0000 Subject: [PATCH 12/21] docs: update version references to 1.0.13-preview.4 --- java/README.md | 8 ++++---- java/sdk/jbang-example.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/java/README.md b/java/README.md index 77b68bdf75..f3ba3f63a0 100644 --- a/java/README.md +++ b/java/README.md @@ -36,14 +36,14 @@ Replace `${copilot.sdk.version}` with the latest release from Maven Central. com.github copilot-sdk-java - 1.0.13-preview.3 + 1.0.13-preview.4 ``` ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.13-preview.3' +implementation 'com.github:copilot-sdk-java:1.0.13-preview.4' ``` #### Snapshot Builds @@ -62,7 +62,7 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.14-preview.3-SNAPSHOT + 1.0.14-preview.4-SNAPSHOT ``` @@ -71,7 +71,7 @@ Snapshot builds of the next development version are published to Maven Central S Replace `${copilot.sdk.version}` with the latest release from Maven Central. ```groovy -implementation 'com.github:copilot-sdk-java:1.0.14-preview.3-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.14-preview.4-SNAPSHOT' ``` ## In-process mode (experimental) diff --git a/java/sdk/jbang-example.java b/java/sdk/jbang-example.java index afff013803..0a1505f9c2 100644 --- a/java/sdk/jbang-example.java +++ b/java/sdk/jbang-example.java @@ -1,5 +1,5 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS com.github:copilot-sdk-java:1.0.13-preview.3 +//DEPS com.github:copilot-sdk-java:1.0.13-preview.4 import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; From fc78bf6cbb114814141d936099190e320b55a89c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 08:56:41 +0000 Subject: [PATCH 13/21] [maven-release-plugin] prepare release java/v1.0.13-preview.4 --- java/copilot-native/pom.xml | 4 ++-- java/pom.xml | 4 ++-- java/sdk/pom.xml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml index 21629a33f9..9ee2c1850f 100644 --- a/java/copilot-native/pom.xml +++ b/java/copilot-native/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.3-SNAPSHOT + 1.0.13-preview.4 ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.4 diff --git a/java/pom.xml b/java/pom.xml index bd317cd197..40d175edc7 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.3-SNAPSHOT + 1.0.13-preview.4 pom GitHub Copilot SDK :: Java :: Parent @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.4 diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index 46ae52e487..67a326ed11 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.14-preview.3-SNAPSHOT + 1.0.13-preview.4 ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - HEAD + java/v1.0.13-preview.4 From 5b3a03e2d5615076598b9421cd2bced93f4612e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 08:56:46 +0000 Subject: [PATCH 14/21] [maven-release-plugin] prepare for next development iteration --- java/copilot-native/pom.xml | 4 ++-- java/pom.xml | 4 ++-- java/sdk/pom.xml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/java/copilot-native/pom.xml b/java/copilot-native/pom.xml index 9ee2c1850f..c03be9909e 100644 --- a/java/copilot-native/pom.xml +++ b/java/copilot-native/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.4 + 1.0.14-preview.4-SNAPSHOT ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.4 + HEAD diff --git a/java/pom.xml b/java/pom.xml index 40d175edc7..91a97aadff 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.4 + 1.0.14-preview.4-SNAPSHOT pom GitHub Copilot SDK :: Java :: Parent @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.4 + HEAD diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index 67a326ed11..bb7cd2fa36 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -8,7 +8,7 @@ com.github copilot-sdk-java-parent - 1.0.13-preview.4 + 1.0.14-preview.4-SNAPSHOT ../pom.xml @@ -24,7 +24,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.13-preview.4 + HEAD From 759156077163205bbb7235c3467856ed69de9d10 Mon Sep 17 00:00:00 2001 From: Quim Muntal Date: Tue, 1 Sep 2026 13:47:45 +0000 Subject: [PATCH 15/21] [go] Auto-detect bundler package name (#2452) * [go] Auto-detect bundler package name * [go] Respect target constraints in package detection --- go/cmd/bundler/main.go | 73 ++++++++++++++++++++++++++++++++++--- go/cmd/bundler/main_test.go | 56 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go index fb11ff8699..e7bbd7a64e 100644 --- a/go/cmd/bundler/main.go +++ b/go/cmd/bundler/main.go @@ -19,6 +19,9 @@ import ( "encoding/json" "flag" "fmt" + "go/build" + "go/parser" + "go/token" "io" "net/http" "os" @@ -33,10 +36,11 @@ import ( const ( // Keep these URLs centralized so reviewers can verify all outbound calls in one place. - sdkModule = "github.com/github/copilot-sdk/go" - packageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json" - tarballURLFmt = "https://registry.npmjs.org/@github/copilot-%s/-/copilot-%s-%s.tgz" - licenseTarballFmt = "https://registry.npmjs.org/@github/copilot/-/copilot-%s.tgz" + sdkModule = "github.com/github/copilot-sdk/go" + packageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json" + tarballURLFmt = "https://registry.npmjs.org/@github/copilot-%s/-/copilot-%s-%s.tgz" + licenseTarballFmt = "https://registry.npmjs.org/@github/copilot/-/copilot-%s.tgz" + defaultPackageName = "main" ) // Platform info: npm package suffix, binary name @@ -89,6 +93,11 @@ func main() { return } + pkgName, err := detectPackageName(*output, goos, goarch) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to detect package name: %v; using package %s\n", err, pkgName) + } + fmt.Printf("Building bundle for %s (CLI version %s)\n", *platform, version) bundle, err := buildBundle(info, version, outputPath, goos) @@ -137,7 +146,7 @@ func main() { muslBundle.wrapperHash, muslBundle.assetsArtifactPath, muslBundle.assetsHash, - "main", + pkgName, ); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -193,6 +202,60 @@ func validPlatforms() []string { return result } +// detectPackageName reads package clauses from files that match the target +// platform and build constraints. It returns defaultPackageName with an error +// when detection fails. +func detectPackageName(dir, goos, goarch string) (string, error) { + if dir == "" { + dir = "." + } + + entries, err := os.ReadDir(dir) + if err != nil { + return defaultPackageName, fmt.Errorf("failed to read package directory %q: %w", dir, err) + } + + buildContext := build.Default + buildContext.GOOS = goos + buildContext.GOARCH = goarch + + packageName := "" + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || + strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || + strings.HasSuffix(name, "_test.go") || strings.HasPrefix(name, "zcopilot_") { + continue + } + matches, err := buildContext.MatchFile(dir, name) + if err != nil { + return defaultPackageName, fmt.Errorf("failed to evaluate build constraints in %q: %w", filepath.Join(dir, name), err) + } + if !matches { + continue + } + + path := filepath.Join(dir, name) + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.PackageClauseOnly) + if err != nil { + return defaultPackageName, fmt.Errorf("failed to parse package clause in %q: %w", path, err) + } + + if packageName == "" { + packageName = file.Name.Name + continue + } + if packageName != file.Name.Name { + return defaultPackageName, fmt.Errorf("multiple packages %q and %q found in %q", packageName, file.Name.Name, dir) + } + } + + if packageName == "" { + return defaultPackageName, fmt.Errorf("no Go package found in %q", dir) + } + return packageName, nil +} + // detectCLIVersion detects the CLI version by: // 1. Running "go list -m" to get the copilot-sdk version from the user's go.mod // 2. Fetching the package-lock.json from the SDK repo at that version diff --git a/go/cmd/bundler/main_test.go b/go/cmd/bundler/main_test.go index 0a7f1c3876..f41afe43f5 100644 --- a/go/cmd/bundler/main_test.go +++ b/go/cmd/bundler/main_test.go @@ -101,6 +101,62 @@ func readTarGz(t *testing.T, path string) map[string]string { } } +func TestDetectPackageName(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "app_linux.go": "package application\n", + "app_test.go": "package application_test\n", + "app_windows.go": "package windowsapplication\n", + "tagged.go": "//go:build windows\n\npackage windowsapplication\n", + "zcopilot_linux_amd64.go": "package main\n", + "_ignored.go": "package ignored\n", + "zcopilot_inprocess_linux.go": "package main\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + for _, test := range []struct { + goos string + want string + }{ + {goos: "linux", want: "application"}, + {goos: "windows", want: "windowsapplication"}, + } { + t.Run(test.goos, func(t *testing.T) { + got, err := detectPackageName(dir, test.goos, "amd64") + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("detectPackageName() = %q, want %q", got, test.want) + } + }) + } +} + +func TestDetectPackageNameFallsBackForMultiplePackages(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + "one.go": "package one\n", + "two.go": "package two\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + got, err := detectPackageName(dir, "linux", "amd64") + if err == nil { + t.Fatal("detectPackageName() succeeded for a directory containing multiple packages") + } + if got != defaultPackageName { + t.Fatalf("detectPackageName() = %q, want fallback %q", got, defaultPackageName) + } +} + func TestGenerateGoFileEmbedsRuntimeWrapperPair(t *testing.T) { dir := t.TempDir() binaryPath := filepath.Join(dir, "copilot.zst") From 80d09ff1660cd7be1be65a0045b82d71cd8841e7 Mon Sep 17 00:00:00 2001 From: Jeremy Moseley Date: Tue, 1 Sep 2026 16:15:16 +0000 Subject: [PATCH 16/21] Expose session feature flag overrides across SDKs (#2451) * Expose Rust session feature flag overrides Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9b0b5b94-f94a-4df4-8e57-6d7ed6fe04c1 * Expose session feature flags across SDKs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document Python feature flag options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Steve Sanderson Copilot-Session: 9b0b5b94-f94a-4df4-8e57-6d7ed6fe04c1 --- dotnet/src/Client.cs | 4 ++ dotnet/src/Types.cs | 9 +++ dotnet/test/Unit/SerializationTests.cs | 40 +++++++++++ go/client.go | 6 ++ go/client_test.go | 56 +++++++++++++++ go/types.go | 8 +++ .../github/copilot/SessionRequestBuilder.java | 2 + .../copilot/rpc/CreateSessionRequest.java | 13 ++++ .../copilot/rpc/ResumeSessionConfig.java | 19 ++++++ .../copilot/rpc/ResumeSessionRequest.java | 13 ++++ .../com/github/copilot/rpc/SessionConfig.java | 19 ++++++ .../copilot/SessionRequestBuilderTest.java | 20 ++++++ nodejs/src/client.ts | 2 + nodejs/src/types.ts | 6 ++ nodejs/test/client.test.ts | 33 +++++++++ python/copilot/client.py | 13 ++++ python/test_client.py | 35 ++++++++++ rust/src/types.rs | 68 +++++++++++++++++++ rust/src/wire.rs | 5 ++ 19 files changed, 371 insertions(+) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 092112cebc..141473f535 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1285,6 +1285,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, + FeatureFlags: config.FeatureFlags, ExpAssignments: config.ExpAssignments, EnableManagedSettings: config.EnableManagedSettings, GitHubMcpToolConfig: config.GitHubMcpToolConfig, @@ -1526,6 +1527,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, + FeatureFlags: config.FeatureFlags, ExpAssignments: config.ExpAssignments, EnableManagedSettings: config.EnableManagedSettings, GitHubMcpToolConfig: config.GitHubMcpToolConfig, @@ -3021,6 +3023,7 @@ internal record CreateSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, + [property: JsonPropertyName("featureFlags")] IDictionary? FeatureFlags = null, [property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null, [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, [property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null, @@ -3139,6 +3142,7 @@ internal record ResumeSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, + [property: JsonPropertyName("featureFlags")] IDictionary? FeatureFlags = null, [property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null, [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, [property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6cf02d2a73..a7de7d85e4 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3225,6 +3225,9 @@ protected SessionConfigBase(SessionConfigBase? other) GitHubToken = other.GitHubToken; GitHubTokenProvider = other.GitHubTokenProvider; RemoteSession = other.RemoteSession; + FeatureFlags = other.FeatureFlags is not null + ? new Dictionary(other.FeatureFlags) + : null; ExpAssignments = other.ExpAssignments; EnableManagedSettings = other.EnableManagedSettings; ManagedSettings = other.ManagedSettings; @@ -3725,6 +3728,12 @@ protected SessionConfigBase(SessionConfigBase? other) [EditorBrowsable(EditorBrowsableState.Never)] public CopilotExpAssignmentResponse? ExpAssignments { get; set; } + ///

+ /// Feature-flag values resolved by the host for this session. + /// Re-supply them when resuming after a runtime restart. + /// + public IDictionary? FeatureFlags { get; set; } + /// /// Opt-in: when true, the runtime self-fetches enterprise managed /// settings (bypass-permissions policy) at session bootstrap using the diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 6edf168093..2414093797 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -630,6 +630,46 @@ public void ResumeSessionConfigClone_PreservesExpAssignments() Assert.Equal("exp-resume", clone.ExpAssignments!.Configs[0].Id); } + [Fact] + public void SessionRequests_CanSerializeFeatureFlags_WithSdkOptions() + { + var options = GetSerializerOptions(); + var flags = new Dictionary + { + ["ENABLED_TEST_FLAG"] = true, + ["DISABLED_TEST_FLAG"] = false, + }; + + foreach (var requestName in new[] { "CreateSessionRequest", "ResumeSessionRequest" }) + { + var requestType = GetNestedType(typeof(CopilotClient), requestName); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("FeatureFlags", flags)); + using var document = JsonDocument.Parse( + JsonSerializer.Serialize(request, requestType, options)); + var serializedFlags = document.RootElement.GetProperty("featureFlags"); + Assert.True(serializedFlags.GetProperty("ENABLED_TEST_FLAG").GetBoolean()); + Assert.False(serializedFlags.GetProperty("DISABLED_TEST_FLAG").GetBoolean()); + } + } + + [Fact] + public void SessionConfigClone_CopiesFeatureFlags() + { + var config = new SessionConfig + { + FeatureFlags = new Dictionary { ["TEST_FLAG"] = true }, + }; + + var clone = config.Clone(); + clone.FeatureFlags!["TEST_FLAG"] = false; + + Assert.True(config.FeatureFlags["TEST_FLAG"]); + Assert.False(clone.FeatureFlags["TEST_FLAG"]); + } + [Fact] public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index 1b45a9f142..8f6b41a983 100644 --- a/go/client.go +++ b/go/client.go @@ -906,6 +906,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ExtensionSDKPath = config.ExtensionSDKPath req.ExtensionInfo = config.ExtensionInfo req.ExpAssignments = config.ExpAssignments + if config.FeatureFlags != nil { + req.FeatureFlags = &config.FeatureFlags + } req.EnableManagedSettings = config.EnableManagedSettings req.ManagedSettings = config.ManagedSettings @@ -1316,6 +1319,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ExtensionSDKPath = config.ExtensionSDKPath req.ExtensionInfo = config.ExtensionInfo req.ExpAssignments = config.ExpAssignments + if config.FeatureFlags != nil { + req.FeatureFlags = &config.FeatureFlags + } req.EnableManagedSettings = config.EnableManagedSettings req.ManagedSettings = config.ManagedSettings if config.OnPermissionRequest != nil { diff --git a/go/client_test.go b/go/client_test.go index d49aff1cc1..ec3924f0a9 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -4012,6 +4012,62 @@ func TestResumeSessionRequest_ExpAssignments(t *testing.T) { }) } +func TestSessionRequests_FeatureFlags(t *testing.T) { + featureFlags := map[string]bool{ + "ENABLED_TEST_FLAG": true, + "DISABLED_TEST_FLAG": false, + } + + for _, tc := range []struct { + name string + req any + }{ + {"create", createSessionRequest{FeatureFlags: &featureFlags}}, + {"resume", resumeSessionRequest{SessionID: "s1", FeatureFlags: &featureFlags}}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := json.Marshal(tc.req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + got := payload["featureFlags"].(map[string]any) + if got["ENABLED_TEST_FLAG"] != true || got["DISABLED_TEST_FLAG"] != false { + t.Errorf("Unexpected featureFlags: %v", got) + } + }) + } + + emptyFlags := map[string]bool{} + for _, tc := range []struct { + name string + req any + }{ + {"create empty", createSessionRequest{FeatureFlags: &emptyFlags}}, + {"resume empty", resumeSessionRequest{SessionID: "s1", FeatureFlags: &emptyFlags}}, + {"create unset", createSessionRequest{}}, + {"resume unset", resumeSessionRequest{SessionID: "s1"}}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := json.Marshal(tc.req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + _, present := payload["featureFlags"] + if strings.Contains(tc.name, "empty") != present { + t.Errorf("featureFlags presence = %v for %s", present, tc.name) + } + }) + } +} + func TestIsTerminal(t *testing.T) { t.Run("IsTerminal is serialized in tool definition", func(t *testing.T) { tool := Tool{ diff --git a/go/types.go b/go/types.go index a574168aba..3577731187 100644 --- a/go/types.go +++ b/go/types.go @@ -1574,6 +1574,9 @@ type SessionConfig struct { // intended for trusted out-of-process integrators, and is not intended for // general external use. ExpAssignments *CopilotExpAssignmentResponse + // FeatureFlags contains feature-flag values resolved by the host for this session. + // Re-supply them when resuming after a runtime restart. + FeatureFlags map[string]bool // EnableManagedSettings, when set to true, opts the runtime into // self-fetching enterprise managed settings (bypass-permissions policy) at // session bootstrap using the session's GitHubToken. Requires GitHubToken to @@ -2105,6 +2108,9 @@ type ResumeSessionConfig struct { // intended for trusted out-of-process integrators, and is not intended for // general external use. ExpAssignments *CopilotExpAssignmentResponse + // FeatureFlags contains host-resolved feature-flag values to apply on resume. + // See SessionConfig.FeatureFlags. + FeatureFlags map[string]bool // EnableManagedSettings injects the same opt-in flag on resume. See // SessionConfig.EnableManagedSettings. Re-supply on resume so the runtime // re-applies the managed-settings self-fetch after a CLI process restart. @@ -2578,6 +2584,7 @@ type createSessionRequest struct { ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments *CopilotExpAssignmentResponse `json:"expAssignments,omitempty"` + FeatureFlags *map[string]bool `json:"featureFlags,omitempty"` EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` ManagedSettings *ManagedSettings `json:"managedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` @@ -2678,6 +2685,7 @@ type resumeSessionRequest struct { ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments *CopilotExpAssignmentResponse `json:"expAssignments,omitempty"` + FeatureFlags *map[string]bool `json:"featureFlags,omitempty"` EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` ManagedSettings *ManagedSettings `json:"managedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index e0a8655390..d54889cc4b 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -201,6 +201,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setGitHubToken(config.getGitHubToken()); request.setRemoteSession(config.getRemoteSession()); request.setCloud(config.getCloud()); + request.setFeatureFlags(config.getFeatureFlags()); request.setExpAssignments(config.getExpAssignments()); config.getEnableManagedSettings().ifPresent(request::setEnableManagedSettings); request.setManagedSettings(config.getManagedSettings()); @@ -340,6 +341,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo } request.setGitHubToken(config.getGitHubToken()); request.setRemoteSession(config.getRemoteSession()); + request.setFeatureFlags(config.getFeatureFlags()); request.setExpAssignments(config.getExpAssignments()); config.getEnableManagedSettings().ifPresent(request::setEnableManagedSettings); request.setManagedSettings(config.getManagedSettings()); diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index ef3531fa5f..f6b5001e7a 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -239,6 +239,9 @@ public final class CreateSessionRequest { @JsonProperty("expAssignments") private CopilotExpAssignmentResponse expAssignments; + @JsonProperty("featureFlags") + private Map featureFlags; + @JsonProperty("enableManagedSettings") @JsonInclude(JsonInclude.Include.NON_NULL) private Boolean enableManagedSettings; @@ -1126,6 +1129,16 @@ public void setExpAssignments(CopilotExpAssignmentResponse expAssignments) { this.expAssignments = expAssignments; } + /** Gets host-resolved feature flags. @return the feature flags */ + public Map getFeatureFlags() { + return featureFlags; + } + + /** Sets host-resolved feature flags. @param featureFlags the feature flags */ + public void setFeatureFlags(Map featureFlags) { + this.featureFlags = featureFlags; + } + /** * Gets the self-fetch managed settings flag. @return the flag, or {@code null} * if not set diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index d13b67d715..ac4f71e07b 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -112,6 +112,7 @@ public class ResumeSessionConfig { private String remoteSession; private CopilotExpAssignmentResponse expAssignments; private Boolean enableManagedSettings; + private Map featureFlags; private ManagedSettings managedSettings; /** @@ -1995,6 +1996,23 @@ public ResumeSessionConfig setExpAssignments(CopilotExpAssignmentResponse expAss return this; } + /** Gets host-resolved feature-flag values. @return the feature flags */ + public Map getFeatureFlags() { + return featureFlags; + } + + /** + * Sets feature-flag values resolved by the host to apply on resume. + * + * @param featureFlags + * the feature flags + * @return this config for method chaining + */ + public ResumeSessionConfig setFeatureFlags(Map featureFlags) { + this.featureFlags = featureFlags; + return this; + } + /** * Gets whether the runtime self-fetches enterprise managed settings at session * bootstrap on resume. @@ -2129,6 +2147,7 @@ public ResumeSessionConfig clone() { copy.gitHubToken = this.gitHubToken; copy.gitHubTokenProvider = this.gitHubTokenProvider; copy.remoteSession = this.remoteSession; + copy.featureFlags = this.featureFlags != null ? new java.util.HashMap<>(this.featureFlags) : null; copy.expAssignments = this.expAssignments; copy.enableManagedSettings = this.enableManagedSettings; copy.managedSettings = this.managedSettings; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 99e4090438..776d58137b 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -241,6 +241,9 @@ public final class ResumeSessionRequest { @JsonProperty("expAssignments") private CopilotExpAssignmentResponse expAssignments; + @JsonProperty("featureFlags") + private Map featureFlags; + @JsonProperty("enableManagedSettings") @JsonInclude(JsonInclude.Include.NON_NULL) private Boolean enableManagedSettings; @@ -1141,6 +1144,16 @@ public void setExpAssignments(CopilotExpAssignmentResponse expAssignments) { this.expAssignments = expAssignments; } + /** Gets host-resolved feature flags. @return the feature flags */ + public Map getFeatureFlags() { + return featureFlags; + } + + /** Sets host-resolved feature flags. @param featureFlags the feature flags */ + public void setFeatureFlags(Map featureFlags) { + this.featureFlags = featureFlags; + } + /** * Gets the self-fetch managed settings flag. @return the flag, or {@code null} * if not set diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index cc2a858f68..cbcacd9771 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -113,6 +113,7 @@ public class SessionConfig { private CloudSessionOptions cloud; private CopilotExpAssignmentResponse expAssignments; private Boolean enableManagedSettings; + private Map featureFlags; private ManagedSettings managedSettings; /** @@ -2124,6 +2125,23 @@ public SessionConfig setExpAssignments(CopilotExpAssignmentResponse expAssignmen return this; } + /** Gets host-resolved feature-flag values. @return the feature flags */ + public Map getFeatureFlags() { + return featureFlags; + } + + /** + * Sets feature-flag values resolved by the host for this session. + * + * @param featureFlags + * the feature flags + * @return this config instance for method chaining + */ + public SessionConfig setFeatureFlags(Map featureFlags) { + this.featureFlags = featureFlags; + return this; + } + /** * Gets whether the runtime self-fetches enterprise managed settings at session * bootstrap. @@ -2271,6 +2289,7 @@ public SessionConfig clone() { copy.gitHubTokenProvider = this.gitHubTokenProvider; copy.remoteSession = this.remoteSession; copy.cloud = this.cloud; + copy.featureFlags = this.featureFlags != null ? new java.util.HashMap<>(this.featureFlags) : null; copy.expAssignments = this.expAssignments; copy.enableManagedSettings = this.enableManagedSettings; copy.managedSettings = this.managedSettings; diff --git a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 5ea8c69274..edc40175d0 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -1092,6 +1092,26 @@ void testBuildRequestsOmitExpAssignmentsWhenUnset() throws Exception { assertFalse(resumeJson.contains("\"expAssignments\""), "expAssignments should be omitted when null"); } + @Test + void testBuildRequestsPropagateAndSerializeFeatureFlags() throws Exception { + var mapper = JsonRpcClient.getObjectMapper(); + var flags = Map.of("ENABLED_TEST_FLAG", true, "DISABLED_TEST_FLAG", false); + + var createConfig = new SessionConfig().setFeatureFlags(flags); + CreateSessionRequest createRequest = SessionRequestBuilder.buildCreateRequest(createConfig, "session-1"); + assertEquals(flags, createRequest.getFeatureFlags()); + var createJson = mapper.readTree(mapper.writeValueAsString(createRequest)); + assertTrue(createJson.path("featureFlags").path("ENABLED_TEST_FLAG").asBoolean()); + assertFalse(createJson.path("featureFlags").path("DISABLED_TEST_FLAG").asBoolean()); + + var resumeConfig = new ResumeSessionConfig().setFeatureFlags(flags); + ResumeSessionRequest resumeRequest = SessionRequestBuilder.buildResumeRequest("session-1", resumeConfig); + assertEquals(flags, resumeRequest.getFeatureFlags()); + var resumeJson = mapper.readTree(mapper.writeValueAsString(resumeRequest)); + assertTrue(resumeJson.path("featureFlags").path("ENABLED_TEST_FLAG").asBoolean()); + assertFalse(resumeJson.path("featureFlags").path("DISABLED_TEST_FLAG").asBoolean()); + } + @Test void testClonePreservesAndForwardsExpAssignments() throws Exception { var mapper = JsonRpcClient.getObjectMapper(); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4f460e9577..acaf67ba85 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1749,6 +1749,7 @@ export class CopilotClient { gitHubTokenProviderRegistrationId, remoteSession: config.remoteSession, cloud: config.cloud, + featureFlags: config.featureFlags, expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, managedSettings: config.managedSettings, @@ -2020,6 +2021,7 @@ export class CopilotClient { gitHubTokenProviderRegistrationId, remoteSession: config.remoteSession, openCanvases: config.openCanvases, + featureFlags: config.featureFlags, expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, managedSettings: config.managedSettings, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e6f90490ed..85c77f8340 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2886,6 +2886,12 @@ export interface SessionConfigBase { */ createSessionFsProvider?: (session: CopilotSession) => SessionFsProvider; + /** + * Feature-flag values resolved by the host for this session. + * Re-supply them when resuming after a runtime restart. + */ + featureFlags?: Record; + /** * ExP assignment ("flight") data injected by a trusted integrator, in the * same JSON shape the Copilot CLI fetches from the experimentation service diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 4017d00637..a08e4a6e5a 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1331,6 +1331,39 @@ describe("CopilotClient", () => { expect(resumePayload.expAssignments).toBeUndefined(); }); + it("forwards featureFlags in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + 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 featureFlags = { ENABLED_TEST_FLAG: true, DISABLED_TEST_FLAG: false }; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + featureFlags, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + featureFlags, + }); + + 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.featureFlags).toEqual(featureFlags); + expect(resumePayload.featureFlags).toEqual(featureFlags); + }); + it("forwards capi options in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 1c0691b612..e50e5fa307 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2250,6 +2250,7 @@ async def create_session( extension_info: ExtensionInfo | None = None, canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, + feature_flags: dict[str, bool] | None = None, exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, github_mcp_tool_config: GitHubMcpToolConfig | None = None, @@ -2393,6 +2394,9 @@ async def create_session( on its own and has no effect unless MCP Apps are enabled for the session (see ``enable_mcp_apps``). Omitted from the wire payload entirely when None. + feature_flags: Feature-flag values resolved by the host for this + session. Re-supply them when resuming after a runtime restart. + Sent on the wire as ``featureFlags``. exp_assignments: ExP assignment ("flight") data injected by a trusted integrator, in the same JSON shape the Copilot CLI fetches from the experimentation service @@ -2571,6 +2575,9 @@ async def create_session( if cloud is not None: payload["cloud"] = _cloud_session_options_to_dict(cloud) + if feature_flags is not None: + payload["featureFlags"] = feature_flags + # Add ExP assignment data if provided (trusted integrator) if exp_assignments is not None: payload["expAssignments"] = _exp_assignment_response_to_dict(exp_assignments) @@ -3021,6 +3028,7 @@ async def resume_session( canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, open_canvases: list[OpenCanvasInstance] | None = None, + feature_flags: dict[str, bool] | None = None, exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, github_mcp_tool_config: GitHubMcpToolConfig | None = None, @@ -3166,6 +3174,8 @@ async def resume_session( tool calls or permission prompts that were still pending when the session was last suspended. When False (the default), the runtime treats pending work as interrupted on resume. + feature_flags: Feature-flag values resolved by the host to apply + on resume. Sent on the wire as ``featureFlags``. exp_assignments: ExP assignment ("flight") data injected by a trusted integrator, in the same JSON shape the Copilot CLI fetches from the experimentation service @@ -3364,6 +3374,9 @@ async def resume_session( if remote_session is not None: payload["remoteSession"] = remote_session.value + if feature_flags is not None: + payload["featureFlags"] = feature_flags + # Add ExP assignment data if provided (trusted integrator) if exp_assignments is not None: payload["expAssignments"] = _exp_assignment_response_to_dict(exp_assignments) diff --git a/python/test_client.py b/python/test_client.py index cb78db2094..e475dba0e2 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1412,6 +1412,41 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_feature_flags(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + feature_flags = {"ENABLED_TEST_FLAG": True, "DISABLED_TEST_FLAG": False} + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + feature_flags=feature_flags, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + feature_flags=feature_flags, + ) + + assert captured["session.create"]["featureFlags"] == feature_flags + assert captured["session.resume"]["featureFlags"] == feature_flags + finally: + await client.force_stop() + class TestURLParsing: def test_parse_port_only_url(self): diff --git a/rust/src/types.rs b/rust/src/types.rs index 726bc48f4c..89906f9627 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2158,6 +2158,12 @@ pub struct SessionConfig { /// each command appears as `/name` for the user to invoke and the /// associated [`CommandHandler`] is called when executed. pub commands: Option>, + /// Feature-flag values resolved by the host for this session. + /// + /// Re-supply these values through [`ResumeSessionConfig::feature_flags`] + /// when resuming after a CLI process restart. Set via + /// [`with_feature_flags`](Self::with_feature_flags). + pub feature_flags: Option>, /// ExP assignment ("flight") data injected by a trusted integrator, in /// the same JSON shape the Copilot CLI fetches from the experimentation /// service (`CopilotExpAssignmentResponse`). When supplied, the runtime @@ -2336,6 +2342,7 @@ impl std::fmt::Debug for SessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("feature_flags", &self.feature_flags) .field("exp_assignments", &self.exp_assignments) .field("enable_managed_settings", &self.enable_managed_settings) .field("enable_experimental_mode", &self.enable_experimental_mode) @@ -2453,6 +2460,7 @@ impl Default for SessionConfig { cloud: None, include_sub_agent_streaming_events: None, commands: None, + feature_flags: None, exp_assignments: None, enable_managed_settings: None, managed_settings: None, @@ -2628,6 +2636,7 @@ impl SessionConfig { include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, enable_github_telemetry_forwarding: None, commands: wire_commands, + feature_flags: self.feature_flags, exp_assignments: self.exp_assignments, enable_managed_settings: self.enable_managed_settings, is_experimental_mode: self.enable_experimental_mode, @@ -3267,6 +3276,12 @@ impl SessionConfig { self } + /// Set feature-flag values resolved by the host for this session. + pub fn with_feature_flags(mut self, feature_flags: HashMap) -> Self { + self.feature_flags = Some(feature_flags); + self + } + /// Inject ExP assignment ("flight") data for this session, in the same /// JSON shape the Copilot CLI fetches from the experimentation service /// (`CopilotExpAssignmentResponse`). The runtime feeds it into the same @@ -3495,6 +3510,10 @@ pub struct ResumeSessionConfig { /// [`SessionConfig::commands`] — commands are not persisted server-side, /// so the resume payload re-supplies the registration. pub commands: Option>, + /// Feature-flag values resolved by the host to apply on resume. + /// + /// See [`SessionConfig::feature_flags`]. + pub feature_flags: Option>, /// ExP assignment ("flight") data injected on resume. See /// [`SessionConfig::exp_assignments`]. Re-supply on resume so the runtime /// re-applies the assignments after a CLI process restart. Set via @@ -3659,6 +3678,7 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("feature_flags", &self.feature_flags) .field("exp_assignments", &self.exp_assignments) .field("enable_managed_settings", &self.enable_managed_settings) .field("enable_experimental_mode", &self.enable_experimental_mode) @@ -3826,6 +3846,7 @@ impl ResumeSessionConfig { include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, enable_github_telemetry_forwarding: None, commands: wire_commands, + feature_flags: self.feature_flags, exp_assignments: self.exp_assignments, enable_managed_settings: self.enable_managed_settings, is_experimental_mode: self.enable_experimental_mode, @@ -3926,6 +3947,7 @@ impl ResumeSessionConfig { remote_session: None, include_sub_agent_streaming_events: None, commands: None, + feature_flags: None, exp_assignments: None, enable_managed_settings: None, managed_settings: None, @@ -4551,6 +4573,12 @@ impl ResumeSessionConfig { self } + /// Re-supply feature-flag values resolved by the host on resume. + pub fn with_feature_flags(mut self, feature_flags: HashMap) -> Self { + self.feature_flags = Some(feature_flags); + self + } + /// Inject ExP assignment ("flight") data on resume. See /// [`SessionConfig::with_exp_assignments`]. Re-supply the assignments on /// resume so the runtime re-applies them after a CLI process restart. @@ -6451,6 +6479,46 @@ mod tests { assert!(empty_json.get("memory").is_none()); } + #[test] + fn feature_flags_serialize_on_create_and_resume() { + let feature_flags = HashMap::from([ + ("BACKGROUND_TASK_NOTIFICATION_PAYLOADS".to_string(), true), + ("DISABLED_TEST_FLAG".to_string(), false), + ]); + let expected = serde_json::json!({ + "BACKGROUND_TASK_NOTIFICATION_PAYLOADS": true, + "DISABLED_TEST_FLAG": false, + }); + + let create_config = SessionConfig::default().with_feature_flags(feature_flags.clone()); + assert_eq!(create_config.feature_flags.as_ref(), Some(&feature_flags)); + let (create_wire, _) = create_config + .into_wire(Some(SessionId::from("feature-flags-create"))) + .expect("no duplicate handlers"); + let create_json = serde_json::to_value(&create_wire).unwrap(); + assert_eq!(create_json["featureFlags"], expected); + + let (resume_wire, _) = ResumeSessionConfig::new(SessionId::from("feature-flags-resume")) + .with_feature_flags(feature_flags) + .into_wire() + .expect("no duplicate handlers"); + let resume_json = serde_json::to_value(&resume_wire).unwrap(); + assert_eq!(resume_json["featureFlags"], expected); + + let (unset_create_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("feature-flags-create-unset"))) + .expect("no duplicate handlers"); + let unset_create_json = serde_json::to_value(&unset_create_wire).unwrap(); + assert!(unset_create_json.get("featureFlags").is_none()); + + let (unset_resume_wire, _) = + ResumeSessionConfig::new(SessionId::from("feature-flags-resume-unset")) + .into_wire() + .expect("no duplicate handlers"); + let unset_resume_json = serde_json::to_value(&unset_resume_wire).unwrap(); + assert!(unset_resume_json.get("featureFlags").is_none()); + } + fn sample_exp_assignments(context: &str) -> CopilotExpAssignmentResponse { CopilotExpAssignmentResponse { features: vec!["copilot_exp_flag".to_string()], diff --git a/rust/src/wire.rs b/rust/src/wire.rs index ebc050477e..75e17f4e9c 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -13,6 +13,7 @@ //! configs hold trait-object handlers, the wire structs hold only the //! plain data the runtime needs. +use std::collections::HashMap; use std::path::PathBuf; use indexmap::IndexMap; @@ -191,6 +192,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub feature_flags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub exp_assignments: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enable_managed_settings: Option, @@ -351,6 +354,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub continue_pending_work: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub feature_flags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub exp_assignments: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enable_managed_settings: Option, From 913cb61302d6e27789c51dcacccfb193569eea73 Mon Sep 17 00:00:00 2001 From: examon <1676292+examon@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:54:02 +0000 Subject: [PATCH 17/21] docs: remove false 30-minute idle timeout claim from deployment guides (#2417) The Limitations tables in docs/setup/scaling.md and docs/setup/backend-services.md state that the CLI automatically cleans up sessions after 30 minutes of inactivity. There is no such timeout: idle sessions are cleaned up only when a positive --session-idle-timeout is configured on the server, and nothing is configured by default. Replace both rows with the opt-in framing already used by docs/features/session-persistence.md. Both guides describe connecting to a separately launched CLI server, where the SDK-side sessionIdleTimeoutSeconds option is documented as ignored, so the rows point at the --session-idle-timeout server flag instead. Co-authored-by: examon --- docs/setup/backend-services.md | 2 +- docs/setup/scaling.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/setup/backend-services.md b/docs/setup/backend-services.md index 7f1da36e82..b7b288bb8f 100644 --- a/docs/setup/backend-services.md +++ b/docs/setup/backend-services.md @@ -540,7 +540,7 @@ setInterval(() => cleanupSessions(24 * 60 * 60 * 1000), 60 * 60 * 1000); | **Single CLI server = single point of failure** | See [Scaling guide](./scaling.md) for HA patterns | | **No built-in auth between SDK and CLI** | Secure the network path (same host, VPC, etc.) | | **Session state on local disk** | Mount persistent storage for container restarts | -| **30-minute idle timeout** | Sessions without activity are auto-cleaned | +| **No idle timeout by default** | Pass `--session-idle-timeout ` to the CLI server to automatically clean up inactive sessions | ## When to move on diff --git a/docs/setup/scaling.md b/docs/setup/scaling.md index c4a7a0953f..acf82bdb26 100644 --- a/docs/setup/scaling.md +++ b/docs/setup/scaling.md @@ -626,7 +626,7 @@ flowchart TB | **No built-in session locking** | Implement application-level locking for concurrent access | | **No built-in load balancing** | Use external LB or service mesh | | **Session state is file-based** | Requires shared filesystem for multi-server setups | -| **30-minute idle timeout** | Sessions without activity are auto-cleaned by the CLI | +| **No idle timeout by default** | Pass `--session-idle-timeout ` to the CLI server to automatically clean up inactive sessions | | **CLI is single-process** | Scale by adding more CLI server instances, not threads | ## Next steps From 6b0252cf0e8243d5c972d4dea00c1d8862b57d9f Mon Sep 17 00:00:00 2001 From: Quim Muntal Date: Tue, 1 Sep 2026 18:05:14 +0000 Subject: [PATCH 18/21] [go] Avoid duplicate bundler license downloads (#2453) --- go/cmd/bundler/main.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go index e7bbd7a64e..89f99daf1b 100644 --- a/go/cmd/bundler/main.go +++ b/go/cmd/bundler/main.go @@ -124,6 +124,10 @@ func main() { os.Exit(1) } } + if err := downloadCLILicense(version, outputPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to download CLI license: %v\n", err) + os.Exit(1) + } // Generate the Go file with embed directive if err := generateGoFile( @@ -384,9 +388,6 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle if err != nil { return bundleArtifacts{}, fmt.Errorf("failed to hash existing output: %w", err) } - if err := downloadCLILicense(cliVersion, outputPath); err != nil { - return bundleArtifacts{}, fmt.Errorf("failed to download CLI license: %w", err) - } runtimeHash, err := sha256FileFromCompressed(runtimeArtifactPath) if err != nil { return bundleArtifacts{}, fmt.Errorf("failed to hash existing runtime.node: %w", err) @@ -427,9 +428,6 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle if err := compressZstdFile(binaryPath, outputPath); err != nil { return bundleArtifacts{}, fmt.Errorf("failed to write output binary: %w", err) } - if err := downloadCLILicense(cliVersion, outputPath); err != nil { - return bundleArtifacts{}, fmt.Errorf("failed to download CLI license: %w", err) - } rawLibPath := filepath.Join(tempDir, "runtime.node") if err := extractFileFromTarball( From 76f6b6daf7816441d44312f128755d327fbeff5e Mon Sep 17 00:00:00 2001 From: Alberto Gimeno Date: Tue, 1 Sep 2026 18:54:52 +0000 Subject: [PATCH 19/21] Make Rust CLI ownership crash-safe on Windows (#2458) * Fix CLI process tree lifecycle Contain SDK-spawned CLI descendants in Unix process groups and Windows Job Objects, and terminate the complete owned tree during shutdown and failure cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Disarm process tree before root reap Consume process-tree ownership during termination to prevent a second Unix signal after PID reuse, and make the Windows failed-start fixture execute through a PowerShell script. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add real-CLI E2E coverage for descendant and host-crash scenarios - Add stop_terminates_real_cli_wrapper_descendants: a Unix E2E test that launches the real bundled CLI through a shell wrapper (via ClientOptions::prefix_args) that backgrounds a descendant process, and verifies Client::stop() kills that inherited descendant. - Add abrupt_host_termination_still_kills_cli_via_job_object: a Windows E2E test representing the actual github/app#2303 scenario -- an SDK-embedding host process being killed/crashing abruptly, so none of its own cleanup code (Client::stop/force_stop/Drop) ever runs. It spawns a new copilot-host-crash-fixture helper binary that starts a real CLI client and then never calls any SDK teardown code, terminates that helper abruptly, and asserts the CLI still dies via the Job Object's kill-on-close semantics. Manually reproducing the same abrupt-kill scenario on Linux showed the CLI already exits on its own within ~200ms once the OS closes the dead host's end of the stdio pipe, with none of this crate's code involved -- a pre-existing, code-free safety net specific to stdio-piped processes on Unix. That's further evidence Unix process-group containment isn't needed to address #2303's failure mode, which is Windows-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(rust): narrow process ownership to Windows crash safety Use a kill-on-close Job Object only on Windows and test that abruptly terminating the SDK host tears down its owned CLI process. Preserve direct-child behavior on other platforms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(rust): focus process ownership coverage on Windows Keep the real-CLI abrupt-host E2E and remove unsupported Unix descendant coverage and the redundant synthetic crash test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Steve Sanderson --- rust/Cargo.lock | 1 + rust/Cargo.toml | 13 ++ rust/src/lib.rs | 80 ++++++--- rust/src/process_tree.rs | 195 ++++++++++++++++++++++ rust/tests/e2e/client_lifecycle.rs | 147 ++++++++++++++++ rust/tests/fixtures/host_crash_fixture.rs | 60 +++++++ 6 files changed, 471 insertions(+), 25 deletions(-) create mode 100644 rust/src/process_tree.rs create mode 100644 rust/tests/fixtures/host_crash_fixture.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8de6797989..b91eebd06c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -454,6 +454,7 @@ dependencies = [ "tracing", "ureq", "uuid", + "windows-sys 0.61.2", "zip", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a28190cd1f..c7a704fd50 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -70,6 +70,12 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c [target.'cfg(windows)'.dependencies] zip = { version = "2", default-features = false, features = ["deflate"], optional = true } +windows-sys = { version = "0.61", default-features = false, features = [ + "Win32_Foundation", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } [dev-dependencies] rusqlite = { version = "0.35", features = ["bundled"] } @@ -105,6 +111,13 @@ required-features = ["test-support"] test = false bench = false +[[bin]] +name = "copilot-host-crash-fixture" +path = "tests/fixtures/host_crash_fixture.rs" +required-features = ["test-support"] +test = false +bench = false + [build-dependencies] base64 = "0.22" dirs = "5" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1c20fd1837..32d44ddc0f 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -33,6 +33,7 @@ pub mod hooks; mod jsonrpc; /// Permission-policy helpers that produce a [`handler::PermissionHandler`]. pub mod permission; +mod process_tree; /// BYOK bearer-token provider callbacks. pub mod provider_token; mod provider_token_dispatch; @@ -1066,6 +1067,7 @@ impl std::fmt::Debug for Client { struct ClientInner { child: parking_lot::Mutex>, + process_tree: parking_lot::Mutex>, #[cfg(feature = "bundled-in-process")] /// In-process FFI runtime host, set only for [`Transport::InProcess`]. /// Closing it tears down the native runtime connection. @@ -1302,6 +1304,7 @@ impl Client { reader, writer, None, + None, working_directory, options.on_list_models, extension_launch_provider.clone(), @@ -1317,7 +1320,7 @@ impl Client { port, connection_token: _, } => { - let (mut child, actual_port, spawn_elapsed, port_wait_elapsed) = + let (mut child, tree, actual_port, spawn_elapsed, port_wait_elapsed) = Self::spawn_tcp(&program, &options, &working_directory, port).await?; timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed)); timings.port_wait_ms = Some(StartupTimings::millis(port_wait_elapsed)); @@ -1334,6 +1337,7 @@ impl Client { reader, writer, Some(child), + tree, working_directory, options.on_list_models, extension_launch_provider.clone(), @@ -1346,7 +1350,7 @@ impl Client { )? } Transport::Stdio => { - let (mut child, spawn_elapsed) = + let (mut child, tree, spawn_elapsed) = Self::spawn_stdio(&program, &options, &working_directory)?; timings.process_spawn_ms = Some(StartupTimings::millis(spawn_elapsed)); let stdin = child.stdin.take().expect("stdin is piped"); @@ -1356,6 +1360,7 @@ impl Client { stdout, stdin, Some(child), + tree, working_directory, options.on_list_models, extension_launch_provider.clone(), @@ -1422,6 +1427,7 @@ impl Client { reader, writer, None, + None, working_directory, options.on_list_models, extension_launch_provider.clone(), @@ -1563,6 +1569,7 @@ impl Client { reader, writer, None, + None, cwd, None, None, @@ -1589,6 +1596,7 @@ impl Client { reader, writer, None, + None, cwd, None, Some(provider), @@ -1619,6 +1627,7 @@ impl Client { reader, writer, None, + None, cwd, None, None, @@ -1645,6 +1654,7 @@ impl Client { reader, writer, None, + None, cwd, None, None, @@ -1671,6 +1681,7 @@ impl Client { reader, writer, None, + None, cwd, None, None, @@ -1698,6 +1709,7 @@ impl Client { reader: impl AsyncRead + Unpin + Send + 'static, writer: impl AsyncWrite + Unpin + Send + 'static, child: Option, + process_tree: Option, cwd: PathBuf, on_list_models: Option>, extension_launch_provider: Option< @@ -1732,6 +1744,7 @@ impl Client { let client = Self { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(child), + process_tree: parking_lot::Mutex::new(process_tree), #[cfg(feature = "bundled-in-process")] ffi_host: parking_lot::Mutex::new(None), rpc, @@ -1870,13 +1883,6 @@ impl Client { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - command.as_std_mut().creation_flags(CREATE_NO_WINDOW); - } - command } @@ -1933,7 +1939,7 @@ impl Client { program: &Path, options: &ClientOptions, working_directory: &Path, - ) -> Result<(Child, Duration)> { + ) -> Result<(Child, Option, Duration)> { info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)"); let mut command = Self::build_command(program, options, working_directory); command @@ -1945,13 +1951,13 @@ impl Client { .args(&options.extra_args) .stdin(Stdio::piped()); let spawn_start = Instant::now(); - let child = command.spawn()?; + let (child, tree) = process_tree::spawn(&mut command)?; let spawn_elapsed = spawn_start.elapsed(); debug!( elapsed_ms = spawn_elapsed.as_millis(), "Client::spawn_stdio subprocess spawned" ); - Ok((child, spawn_elapsed)) + Ok((child, tree, spawn_elapsed)) } async fn spawn_tcp( @@ -1959,7 +1965,13 @@ impl Client { options: &ClientOptions, working_directory: &Path, port: u16, - ) -> Result<(Child, u16, Duration, Duration)> { + ) -> Result<( + Child, + Option, + u16, + Duration, + Duration, + )> { info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)"); let mut command = Self::build_command(program, options, working_directory); command @@ -1971,7 +1983,7 @@ impl Client { .args(&options.extra_args) .stdin(Stdio::null()); let spawn_start = Instant::now(); - let mut child = command.spawn()?; + let (mut child, tree) = process_tree::spawn(&mut command)?; let spawn_elapsed = spawn_start.elapsed(); debug!( elapsed_ms = spawn_elapsed.as_millis(), @@ -2018,7 +2030,7 @@ impl Client { "Client::spawn_tcp TCP port wait complete" ); info!(port = %actual_port, "CLI server listening"); - Ok((child, actual_port, spawn_elapsed, port_wait_elapsed)) + Ok((child, tree, actual_port, spawn_elapsed, port_wait_elapsed)) } fn drain_stderr(child: &mut Child) { @@ -2561,11 +2573,12 @@ impl Client { /// Cooperatively shut down the client and the CLI child process. /// /// Walks every still-registered session and sends `session.destroy` - /// for each one, asks SDK-owned runtimes to shut down, then kills the - /// CLI child. Errors from per-session destroys, runtime shutdown, and - /// the final child-kill are collected into - /// [`StopErrors`] rather than short-circuiting on the first failure - /// — so callers see the full picture of teardown. + /// for each one, asks SDK-owned runtimes to shut down, terminates the + /// Windows-owned CLI Job Object when present, and reaps the root process. + /// Errors from per-session destroys, runtime shutdown, and final process + /// termination are collected into [`StopErrors`] rather than + /// short-circuiting on the first failure — so callers see the full picture + /// of teardown. /// /// If you have already called [`Session::disconnect`] on every /// session this client created, the per-session destroy step is a @@ -2654,8 +2667,14 @@ impl Client { } let child = self.inner.child.lock().take(); + let process_tree = self.inner.process_tree.lock().take(); *self.inner.state.lock() = ConnectionState::Disconnected; *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new()); + if let Some(process_tree) = process_tree + && let Err(error) = process_tree.terminate() + { + errors.push(error.into()); + } if let Some(mut child) = child { match child.try_wait() { Ok(Some(_status)) => {} @@ -2696,10 +2715,9 @@ impl Client { /// /// Synchronous fallback when [`stop`](Self::stop) is unsuitable — for /// example when the awaiting tokio runtime is shutting down or the - /// process is wedged on I/O. Sends a kill signal without awaiting - /// reaper completion and immediately drops all per-session router - /// state so dependent tasks observe a closed channel rather than a - /// hang. + /// process is wedged on I/O. Terminates the Windows-owned CLI Job Object + /// when present and immediately drops all per-session router state so + /// dependent tasks observe a closed channel rather than a hang. /// /// # Cancel safety /// @@ -2725,6 +2743,11 @@ impl Client { let pid = self.pid(); info!(pid = ?pid, "force-stopping CLI process"); self.inner.extension_launch_provider.clear(); + if let Some(process_tree) = self.inner.process_tree.lock().take() + && let Err(error) = process_tree.terminate() + { + error!(pid = ?pid, %error, "failed to terminate CLI process tree"); + } if let Some(mut child) = self.inner.child.lock().take() && let Err(e) = child.start_kill() { @@ -2786,8 +2809,13 @@ impl Client { impl Drop for ClientInner { fn drop(&mut self) { + let pid = self.child.lock().as_ref().and_then(Child::id); + if let Some(process_tree) = self.process_tree.lock().take() + && let Err(error) = process_tree.terminate() + { + error!(pid = ?pid, %error, "failed to terminate CLI process tree on drop"); + } if let Some(ref mut child) = *self.child.lock() { - let pid = child.id(); if let Err(e) = child.start_kill() { error!(pid = ?pid, error = %e, "failed to kill CLI process on drop"); } else { @@ -3447,6 +3475,7 @@ mod tests { client_read, client_write, Some(child), + None, temp.path().to_path_buf(), None, None, @@ -3536,6 +3565,7 @@ mod tests { Client { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(None), + process_tree: parking_lot::Mutex::new(None), #[cfg(feature = "bundled-in-process")] ffi_host: parking_lot::Mutex::new(None), rpc: { diff --git a/rust/src/process_tree.rs b/rust/src/process_tree.rs new file mode 100644 index 0000000000..8dc7a451c5 --- /dev/null +++ b/rust/src/process_tree.rs @@ -0,0 +1,195 @@ +//! Windows crash-safe ownership of an SDK-spawned CLI process. +//! +//! A Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` lets Windows +//! terminate the CLI when the SDK-hosting process exits abruptly, even when +//! Rust cleanup code never runs. Other platforms retain Tokio's direct-child +//! ownership because no equivalent product failure has been demonstrated. + +use std::io; + +use tokio::process::{Child, Command}; + +pub(crate) fn spawn(command: &mut Command) -> io::Result<(Child, Option)> { + #[cfg(windows)] + { + platform::spawn(command).map(|(child, tree)| (child, Some(ProcessTree(Some(tree))))) + } + #[cfg(not(windows))] + { + command.spawn().map(|child| (child, None)) + } +} + +pub(crate) struct ProcessTree(Option); + +impl ProcessTree { + pub(crate) fn terminate(mut self) -> io::Result<()> { + self.0.take().expect("process tree is armed").terminate() + } +} + +impl Drop for ProcessTree { + fn drop(&mut self) { + if let Some(tree) = self.0.take() { + let _ = tree.terminate(); + } + } +} + +#[cfg(not(windows))] +mod platform { + pub(super) struct Tree; + + impl Tree { + pub(super) fn terminate(&self) -> std::io::Result<()> { + unreachable!("process-tree ownership is Windows-only") + } + } +} + +#[cfg(windows)] +mod platform { + use std::mem::size_of; + use std::os::windows::process::CommandExt; + use std::{io, ptr}; + + use tokio::process::{Child, Command}; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, + }; + use windows_sys::Win32::System::Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, OpenThread, ResumeThread, THREAD_SUSPEND_RESUME, + }; + + struct OwnedHandle(HANDLE); + + // SAFETY: Win32 handles may be used and closed from any thread. + unsafe impl Send for OwnedHandle {} + unsafe impl Sync for OwnedHandle {} + + impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: this value uniquely owns a valid handle. + unsafe { + CloseHandle(self.0); + } + } + } + + pub(super) struct Tree { + job: OwnedHandle, + } + + pub(super) fn spawn(command: &mut Command) -> io::Result<(Child, Tree)> { + // The root cannot run or create descendants before Job assignment. + command + .as_std_mut() + .creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED); + let mut child = command.spawn()?; + match attach_and_resume(&child) { + Ok(tree) => Ok((child, tree)), + Err(error) => { + let _ = child.start_kill(); + Err(error) + } + } + } + + fn attach_and_resume(child: &Child) -> io::Result { + // SAFETY: null security attributes and name create a private, + // non-inheritable Job Object. + let raw_job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) }; + if raw_job.is_null() { + return Err(io::Error::last_os_error()); + } + let job = OwnedHandle(raw_job); + + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` has the layout required by the selected info class. + if unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + ptr::from_ref(&limits).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + let process = child.raw_handle().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "CLI exited before Job Object assignment", + ) + })?; + // SAFETY: both handles are valid and the child is still suspended. + if unsafe { AssignProcessToJobObject(job.0, process.cast()) } == 0 { + return Err(io::Error::last_os_error()); + } + + resume_initial_thread(child.id().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "CLI exited before thread resume") + })?)?; + Ok(Tree { job }) + } + + fn resume_initial_thread(pid: u32) -> io::Result<()> { + // SAFETY: the returned snapshot handle is owned and closed below. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let snapshot = OwnedHandle(snapshot); + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..Default::default() + }; + + // SAFETY: `entry` has the documented size and remains live throughout + // enumeration. + let mut found = unsafe { Thread32First(snapshot.0, &mut entry) } != 0; + while found { + if entry.th32OwnerProcessID == pid { + // SAFETY: the thread id came from the live system snapshot. + let raw_thread = + unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if raw_thread.is_null() { + return Err(io::Error::last_os_error()); + } + let thread = OwnedHandle(raw_thread); + // SAFETY: this is the root's suspended initial thread. + if unsafe { ResumeThread(thread.0) } == u32::MAX { + return Err(io::Error::last_os_error()); + } + return Ok(()); + } + // SAFETY: same valid snapshot and initialized entry as above. + found = unsafe { Thread32Next(snapshot.0, &mut entry) } != 0; + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + "CLI initial thread was not found", + )) + } + + impl Tree { + pub(super) fn terminate(&self) -> io::Result<()> { + // SAFETY: the handle is a live Job Object owned by this value. + if unsafe { TerminateJobObject(self.job.0, 1) } != 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + } +} diff --git a/rust/tests/e2e/client_lifecycle.rs b/rust/tests/e2e/client_lifecycle.rs index 75646b4860..92bfa6ff6d 100644 --- a/rust/tests/e2e/client_lifecycle.rs +++ b/rust/tests/e2e/client_lifecycle.rs @@ -1,3 +1,5 @@ +#[cfg(windows)] +use github_copilot_sdk::CliProgram; use github_copilot_sdk::SessionLifecycleEventType; use serde_json::json; @@ -135,6 +137,151 @@ async fn dispose_disconnects_client_and_disposes_rpc_surface_drop() { .await; } +// This test represents github/app#2303: the SDK-hosting GitHub Copilot app +// process exits abruptly, so Client cleanup never runs. The helper starts a +// real CLI client, is terminated through `TerminateProcess`, and relies only +// on Job Object kill-on-close behavior to terminate the CLI. +#[cfg(windows)] +#[tokio::test] +async fn abrupt_host_termination_still_kills_cli_via_job_object() { + with_e2e_context( + "client_lifecycle", + "abrupt_host_termination_still_kills_cli_via_job_object", + |ctx| { + Box::pin(async move { + let options = ctx.client_options(); + let program = match &options.program { + CliProgram::Path(path) => path + .to_str() + .expect("CLI program path is valid UTF-8") + .to_owned(), + CliProgram::Resolve => { + panic!("E2E client options should resolve to an explicit CLI path") + } + }; + let prefix_args: Vec = options + .prefix_args + .iter() + .map(|arg| arg.to_str().expect("prefix arg is valid UTF-8").to_owned()) + .collect(); + let env_pairs: Vec<(String, String)> = options + .env + .iter() + .map(|(k, v)| { + ( + k.to_str().expect("env key is valid UTF-8").to_owned(), + v.to_str().expect("env value is valid UTF-8").to_owned(), + ) + }) + .collect(); + let cwd = options + .working_directory + .to_str() + .expect("cwd is valid UTF-8") + .to_owned(); + let pid_file = ctx.work_dir().join("host-crash-fixture-cli.pid"); + + let mut host = + std::process::Command::new(env!("CARGO_BIN_EXE_copilot-host-crash-fixture")) + .env("HOST_CRASH_FIXTURE_PROGRAM", &program) + .env( + "HOST_CRASH_FIXTURE_PREFIX_ARGS_JSON", + serde_json::to_string(&prefix_args).expect("serialize prefix args"), + ) + .env("HOST_CRASH_FIXTURE_CWD", &cwd) + .env( + "HOST_CRASH_FIXTURE_ENV_JSON", + serde_json::to_string(&env_pairs).expect("serialize env pairs"), + ) + .env("HOST_CRASH_FIXTURE_PID_FILE", &pid_file) + .spawn() + .expect("spawn host-crash fixture process"); + + let cli_pid = wait_for_pid_file_windows(&pid_file).await; + assert!( + process_alive_windows(cli_pid), + "CLI should be alive before its host process is terminated" + ); + + // `Child::kill` maps to `TerminateProcess`, which runs none + // of the target process's cleanup code. + host.kill().expect("terminate host-crash fixture process"); + host.wait().expect("reap host-crash fixture process"); + + let cli_exited = wait_for_process_exit_windows(cli_pid).await; + if !cli_exited { + kill_process_windows(cli_pid); + } + assert!( + cli_exited, + "CLI survived its abruptly terminated host process; Job Object \ + kill-on-close did not terminate it" + ); + }) + }, + ) + .await; +} + +#[cfg(windows)] +async fn wait_for_pid_file_windows(path: &std::path::Path) -> u32 { + super::support::wait_for_condition("host-crash fixture CLI pid file", || async { + path.exists() + }) + .await; + std::fs::read_to_string(path) + .expect("read host-crash fixture CLI pid") + .trim() + .parse() + .expect("parse host-crash fixture CLI pid") +} + +#[cfg(windows)] +async fn wait_for_process_exit_windows(pid: u32) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while process_alive_windows(pid) { + if std::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + true +} + +#[cfg(windows)] +fn process_alive_windows(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + + // SAFETY: the process handle is closed before returning. + unsafe { + let process = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if process.is_null() { + return false; + } + let alive = WaitForSingleObject(process, 0) == WAIT_TIMEOUT; + CloseHandle(process); + alive + } +} + +#[cfg(windows)] +fn kill_process_windows(pid: u32) { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; + + // SAFETY: the pid came from this test's controlled fixture-spawned CLI. + unsafe { + let process = OpenProcess(PROCESS_TERMINATE, 0, pid); + if !process.is_null() { + TerminateProcess(process, 1); + CloseHandle(process); + } + } +} + #[tokio::test] async fn should_receive_session_updated_lifecycle_event_for_non_ephemeral_activity() { with_e2e_context( diff --git a/rust/tests/fixtures/host_crash_fixture.rs b/rust/tests/fixtures/host_crash_fixture.rs new file mode 100644 index 0000000000..c688cf3e87 --- /dev/null +++ b/rust/tests/fixtures/host_crash_fixture.rs @@ -0,0 +1,60 @@ +//! Test-only binary that hosts a single [`github_copilot_sdk::Client`] and then +//! blocks forever, so an external test can terminate *this* process abruptly +//! (simulating an SDK-embedding app process crashing) without ever running any +//! of this process's own cleanup code (`Client::stop`, `force_stop`, or +//! `Drop`). +//! +//! Configuration is passed entirely through environment variables so the +//! caller doesn't need this crate's non-`pub` types: +//! - `HOST_CRASH_FIXTURE_PROGRAM`: CLI program path. +//! - `HOST_CRASH_FIXTURE_PREFIX_ARGS_JSON`: JSON array of prefix args. +//! - `HOST_CRASH_FIXTURE_CWD`: working directory for the spawned CLI. +//! - `HOST_CRASH_FIXTURE_ENV_JSON`: JSON array of `[key, value]` pairs to set +//! on the spawned CLI's environment. +//! - `HOST_CRASH_FIXTURE_PID_FILE`: path this process writes the CLI child's +//! OS process id to, once the client finishes starting. + +use std::path::PathBuf; + +use github_copilot_sdk::{CliProgram, Client, ClientOptions, Transport}; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let program = std::env::var("HOST_CRASH_FIXTURE_PROGRAM").expect("HOST_CRASH_FIXTURE_PROGRAM"); + let prefix_args: Vec = serde_json::from_str( + &std::env::var("HOST_CRASH_FIXTURE_PREFIX_ARGS_JSON") + .expect("HOST_CRASH_FIXTURE_PREFIX_ARGS_JSON"), + ) + .expect("parse HOST_CRASH_FIXTURE_PREFIX_ARGS_JSON"); + let cwd = std::env::var("HOST_CRASH_FIXTURE_CWD").expect("HOST_CRASH_FIXTURE_CWD"); + let env_pairs: Vec<(String, String)> = serde_json::from_str( + &std::env::var("HOST_CRASH_FIXTURE_ENV_JSON").expect("HOST_CRASH_FIXTURE_ENV_JSON"), + ) + .expect("parse HOST_CRASH_FIXTURE_ENV_JSON"); + let pid_file = PathBuf::from( + std::env::var("HOST_CRASH_FIXTURE_PID_FILE").expect("HOST_CRASH_FIXTURE_PID_FILE"), + ); + + let options = ClientOptions::new() + .with_program(CliProgram::Path(PathBuf::from(program))) + .with_prefix_args(prefix_args) + .with_cwd(PathBuf::from(cwd)) + .with_env(env_pairs) + .with_use_logged_in_user(false) + .with_transport(Transport::Stdio); + + let client = Client::start(options).await.expect("start CLI client"); + let pid = client.pid().expect("client reports spawned CLI pid"); + std::fs::write(&pid_file, pid.to_string()).expect("write pid file"); + + // Deliberately leak the client so nothing in this process — including its + // `Drop` impls — ever runs cleanup code. The external test process + // terminates this process abruptly (e.g. `TerminateProcess` on Windows) + // to simulate an SDK-embedding host crashing, and asserts that the CLI + // still dies via the OS containment primitive alone. + std::mem::forget(client); + + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} From edf11030e5893885508504cfcbae8310b0d528f1 Mon Sep 17 00:00:00 2001 From: Derek Legenzoff Date: Tue, 1 Sep 2026 14:30:02 -0700 Subject: [PATCH 20/21] Add Auto routing tier options across all SDKs (#2437) * Add Auto routing tier options across SDKs Expose typed capi.autoTier options for create and resume across all six SDKs, including Python wire conversion. Cover canonical values and omission, and document runtime-owned persistence and resume semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2417f184-d8c9-4f2a-a27b-e15e4baea808 * Update CLI to 1.0.82-1 and regenerate Auto tier types Refresh CLI dependencies and generated RPC/session-event types across all SDKs. Reuse shared AutoTier definitions where applicable and cover typed lifecycle-event decoding, canonical values, and omission. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2417f184-d8c9-4f2a-a27b-e15e4baea808 * Strengthen CAPI forwarding coverage and remove manual Copy Use distinct create/resume WebSocket options, including alongside an unchanged Auto tier, so payload reuse cannot pass the forwarding regression test. Leave AutoTier traits owned by the Rust generator and explicitly clone the reused test value. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2417f184-d8c9-4f2a-a27b-e15e4baea808 * Fix Java auto tier README sample Add the required permission handler to the Java SessionConfig documentation sample so the doc-sample guard passes.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rerun PR checks Trigger a clean CI run after unrelated Windows E2E flakes.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Steve Sanderson Copilot-Session: 2417f184-d8c9-4f2a-a27b-e15e4baea808 --- docs/features/session-persistence.md | 16 ++++ dotnet/src/Types.cs | 12 +++ .../test/Unit/ClientSessionLifetimeTests.cs | 88 +++++++++++++++++ .../Unit/SessionEventSerializationTests.cs | 39 ++++++++ go/client_test.go | 95 ++++++++++++------- go/session_event_serialization_test.go | 44 +++++++++ go/types.go | 20 ++++ go/zsession_events.go | 4 - java/README.md | 28 ++++++ .../java/com/github/copilot/rpc/AutoTier.java | 63 ++++++++++++ .../copilot/rpc/CapiSessionOptions.java | 31 ++++++ .../copilot/CapiSessionOptionsTest.java | 48 ++++++++++ .../copilot/SessionAutoTierEventTest.java | 67 +++++++++++++ nodejs/README.md | 1 + nodejs/src/index.ts | 1 + nodejs/src/types.ts | 14 ++- nodejs/test/client.test.ts | 70 ++++++++------ nodejs/test/session-event-types.test.ts | 28 ++++++ python/README.md | 1 + python/copilot/__init__.py | 2 + python/copilot/client.py | 25 ++++- python/test_client.py | 69 ++++++++++++-- python/test_event_forward_compatibility.py | 37 ++++++++ rust/README.md | 24 +++++ rust/src/types.rs | 74 ++++++++++++++- rust/tests/api_types_test.rs | 44 ++++++++- 26 files changed, 862 insertions(+), 83 deletions(-) create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/AutoTier.java create mode 100644 java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index 3bfff10d0f..9b0aa9434c 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/session-persistence.md @@ -242,6 +242,7 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `availableTools` | Restrict which tools are available | | `excludedTools` | Disable specific tools | | `provider` | Re-provide BYOK credentials (required for BYOK sessions) | +| `capi.autoTier` | Override the persisted Auto routing preference on cold resume only | | `reasoningEffort` | Adjust reasoning effort level | | `streaming` | Enable/disable streaming responses | | `workingDirectory` | Change the working directory | @@ -253,6 +254,21 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | +### Auto tier persistence + +With `model: "auto"`, the optional `capi.autoTier` setting selects an Auto routing preference: `efficiency`, `balance`, or `intelligence`. In Python, use `capi={"auto_tier": "balance"}`. This requires Copilot CLI `1.0.82-1` or later with V2 Auto routing; V1 Auto requests are unchanged. + +The runtime persists the selected tier, so applications do not need to resend it on every resume: + +* Omitting the tier when creating a session uses the runtime's default routing behavior. +* A cold resume restores the persisted tier. Supplying an explicit tier overrides the restored value for the new activation. +* When resuming a session already resident in the runtime, omitting the tier preserves the current selection, supplying the same tier is a no-op, and supplying a different tier is rejected. +* Older sessions without a persisted tier retain default routing behavior. + +Tier selection is not a live model-switch operation. The SDK forwards the preference; the runtime owns persistence and validation. + +The `session.start` and `session.resume` events expose the selected tier in their optional `data.autoTier` field (`data.auto_tier` in Python). When no tier is selected, the field is omitted. + ### Example: changing model on resume ```typescript diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index a7de7d85e4..8eb5e9bb2c 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2398,6 +2398,18 @@ public sealed class CapiSessionOptions /// [JsonPropertyName("enableWebSocketResponses")] public bool? EnableWebSocketResponses { get; set; } + + /// + /// Routing tier for model auto with V2 Auto. + /// + /// + /// Requires a runtime that supports Auto tiers; it has no effect outside V2 Auto. + /// When omitted, the runtime uses its default on create and preserves the persisted or current + /// tier on resume. An explicit tier overrides the persisted tier on a cold resume; a conflicting + /// tier on a resident session resume is rejected by the runtime. + /// + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } } /// diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index bb0042efdb..a59257b3da 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -464,6 +464,94 @@ public async Task CreateSessionAsync_Omits_CustomAgent_ReasoningEffort_When_Unse Assert.False(agent.TryGetProperty("reasoningEffort", out _)); } + public static TheoryData CapiAutoTiers => new() + { + { AutoTier.Efficiency, "efficiency", null }, + { AutoTier.Balance, "balance", null }, + { AutoTier.Intelligence, "intelligence", null }, + { AutoTier.Efficiency, "efficiency", false }, + { AutoTier.Balance, "balance", false }, + { AutoTier.Intelligence, "intelligence", false }, + }; + + [Theory] + [MemberData(nameof(CapiAutoTiers))] + public async Task SessionRequests_Serialize_CapiAutoTier(AutoTier tier, string expectedTier, bool? enableWebSocketResponses) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var capi = new CapiSessionOptions { AutoTier = tier, EnableWebSocketResponses = enableWebSocketResponses }; + + await using var created = await client.CreateSessionAsync(new SessionConfig + { + Model = "auto", + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + await using var resumed = await client.ResumeSessionAsync("resume-with-auto-tier", new ResumeSessionConfig + { + Model = "auto", + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + foreach (var method in new[] { "session.create", "session.resume" }) + { + var request = Assert.Single(server.Requests, request => request.Method == method); + var serializedCapi = request.Params.GetProperty("capi"); + Assert.Equal(expectedTier, serializedCapi.GetProperty("autoTier").GetString()); + if (enableWebSocketResponses.HasValue) + { + Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean()); + } + else + { + Assert.False(serializedCapi.TryGetProperty("enableWebSocketResponses", out _)); + } + } + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, null)] + [InlineData(true, false)] + public async Task SessionRequests_Omit_CapiAutoTier_WhenUnset(bool includeCapi, bool? enableWebSocketResponses) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var capi = includeCapi ? new CapiSessionOptions { EnableWebSocketResponses = enableWebSocketResponses } : null; + + await using var created = await client.CreateSessionAsync(new SessionConfig + { + Model = "auto", + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + await using var resumed = await client.ResumeSessionAsync("resume-without-auto-tier", new ResumeSessionConfig + { + Capi = capi, + OnPermissionRequest = PermissionHandler.ApproveAll + }); + + foreach (var method in new[] { "session.create", "session.resume" }) + { + var request = Assert.Single(server.Requests, request => request.Method == method); + Assert.Equal(includeCapi, request.Params.TryGetProperty("capi", out var serializedCapi)); + if (includeCapi) + { + Assert.False(serializedCapi.TryGetProperty("autoTier", out _)); + if (enableWebSocketResponses.HasValue) + { + Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean()); + } + else + { + Assert.Empty(serializedCapi.EnumerateObject()); + } + } + } + } + [Fact] public async Task CreateSessionAsync_Forwards_AskUserVariant() { diff --git a/dotnet/test/Unit/SessionEventSerializationTests.cs b/dotnet/test/Unit/SessionEventSerializationTests.cs index 326ac3f3c7..8aea505737 100644 --- a/dotnet/test/Unit/SessionEventSerializationTests.cs +++ b/dotnet/test/Unit/SessionEventSerializationTests.cs @@ -9,6 +9,45 @@ namespace GitHub.Copilot.Test.Unit; public class SessionEventSerializationTests { + public static TheoryData AutoTiers => new() + { + { AutoTier.Efficiency, "efficiency" }, + { AutoTier.Balance, "balance" }, + { AutoTier.Intelligence, "intelligence" }, + { null, null }, + }; + + [Theory] + [MemberData(nameof(AutoTiers))] + public void SessionEvent_Deserializes_AutoTier(AutoTier? expectedTier, string? wireTier) + { + foreach (var eventType in new[] { "session.start", "session.resume" }) + { + var autoTierProperty = wireTier is null ? "" : $""", "autoTier": "{wireTier}" """; + var json = $$""" + { + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-08-28T00:00:00Z", + "parentId": null, + "type": "{{eventType}}", + "data": { + "sessionId": "test-session", "version": 1, + "producer": "copilot", "copilotVersion": "1.0.82-1", + "startTime": "2026-08-28T00:00:00Z", + "resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1 + {{autoTierProperty}} + } + } + """; + + var sessionEvent = SessionEvent.FromJson(json); + var actualTier = eventType == "session.start" + ? Assert.IsType(sessionEvent).Data.AutoTier + : Assert.IsType(sessionEvent).Data.AutoTier; + Assert.Equal(expectedTier, actualTier); + } + } + public static TheoryData JsonElementBackedEvents => new() { { diff --git a/go/client_test.go b/go/client_test.go index ec3924f0a9..bede29b496 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -436,42 +436,63 @@ func newRuntimeShutdownRpcPair(t *testing.T) (*jsonrpc2.Client, *jsonrpc2.Client } func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { - rpcClient, server, _ := newRuntimeShutdownRpcPair(t) - t.Cleanup(server.Stop) - client := &Client{ - client: rpcClient, - RPC: rpc.NewServerRPC(rpcClient), - sessions: make(map[string]*Session), - } + tests := []struct { + name string + capi *CapiSessionOptions + want map[string]any + }{ + {"omitted", nil, nil}, + {"empty", &CapiSessionOptions{}, map[string]any{}}, + {"websocket only", &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, map[string]any{"enableWebSocketResponses": false}}, + {"efficiency", &CapiSessionOptions{AutoTier: AutoTierEfficiency}, map[string]any{"autoTier": "efficiency"}}, + {"balance", &CapiSessionOptions{AutoTier: AutoTierBalance}, map[string]any{"autoTier": "balance"}}, + {"intelligence", &CapiSessionOptions{AutoTier: AutoTierIntelligence}, map[string]any{"autoTier": "intelligence"}}, + {"efficiency with websocket", &CapiSessionOptions{AutoTier: AutoTierEfficiency, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "efficiency", "enableWebSocketResponses": false}}, + {"balance with websocket", &CapiSessionOptions{AutoTier: AutoTierBalance, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "balance", "enableWebSocketResponses": false}}, + {"intelligence with websocket", &CapiSessionOptions{AutoTier: AutoTierIntelligence, EnableWebSocketResponses: Bool(false)}, map[string]any{"autoTier": "intelligence", "enableWebSocketResponses": false}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } - createParams := make(chan json.RawMessage, 1) - server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - createParams <- append(json.RawMessage(nil), params...) - sessionID := sessionIDFromParams(t, params) - return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil - }) + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) - _, err := client.CreateSession(t.Context(), &SessionConfig{ - Capi: &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, - }) - if err != nil { - t.Fatalf("CreateSession failed: %v", err) - } - assertCapiEnableWebSocketResponses(t, <-createParams) + _, err := client.CreateSession(t.Context(), &SessionConfig{ + Model: "auto", + Capi: tt.capi, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertCapiOptions(t, <-createParams, tt.want) - resumeParams := make(chan json.RawMessage, 1) - server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - resumeParams <- append(json.RawMessage(nil), params...) - return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil - }) + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed-capi","workspacePath":"/workspace"}`), nil + }) - _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{ - Capi: &CapiSessionOptions{EnableWebSocketResponses: Bool(false)}, - }) - if err != nil { - t.Fatalf("ResumeSessionWithOptions failed: %v", err) + _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-capi", &ResumeSessionConfig{ + Model: "auto", + Capi: tt.capi, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertCapiOptions(t, <-resumeParams, tt.want) + }) } - assertCapiEnableWebSocketResponses(t, <-resumeParams) } func TestClient_ForwardsAskUserVariantToSessionRequests(t *testing.T) { @@ -728,7 +749,7 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15) } -func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { +func assertCapiOptions(t *testing.T, params json.RawMessage, want map[string]any) { t.Helper() var decoded map[string]any @@ -736,12 +757,18 @@ func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { t.Fatalf("failed to unmarshal request params: %v", err) } + if want == nil { + if _, present := decoded["capi"]; present { + t.Fatalf("expected capi to be omitted, got %v", decoded["capi"]) + } + return + } capi, ok := decoded["capi"].(map[string]any) if !ok { t.Fatalf("expected capi object in request params, got %T", decoded["capi"]) } - if capi["enableWebSocketResponses"] != false { - t.Fatalf("expected capi.enableWebSocketResponses=false, got %v", capi["enableWebSocketResponses"]) + if !reflect.DeepEqual(capi, want) { + t.Fatalf("expected capi %v, got %v", want, capi) } } diff --git a/go/session_event_serialization_test.go b/go/session_event_serialization_test.go index ee9258b225..96bf53bb5e 100644 --- a/go/session_event_serialization_test.go +++ b/go/session_event_serialization_test.go @@ -14,6 +14,50 @@ var _ SessionEventData = (*rpc.UserMessageData)(nil) var _ rpc.EmbeddedTextResourceContents = EmbeddedTextResourceContents{} var _ EmbeddedTextResourceContents = rpc.EmbeddedTextResourceContents{} +func TestSessionEventAutoTier(t *testing.T) { + for _, eventType := range []string{"session.start", "session.resume"} { + for _, tier := range []AutoTier{"", AutoTierEfficiency, AutoTierBalance, AutoTierIntelligence} { + t.Run(eventType+"/"+string(tier), func(t *testing.T) { + data := map[string]any{ + "sessionId": "test-session", "version": 1, + "producer": "copilot", "copilotVersion": "1.0.82-1", + "startTime": "2026-08-28T00:00:00Z", + "resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1, + } + if tier != "" { + data["autoTier"] = tier + } + wire, err := json.Marshal(map[string]any{ + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-08-28T00:00:00Z", "parentId": nil, + "type": eventType, "data": data, + }) + if err != nil { + t.Fatal(err) + } + var event SessionEvent + if err := json.Unmarshal(wire, &event); err != nil { + t.Fatal(err) + } + var actual *AutoTier + switch eventType { + case "session.start": + actual = event.Data.(*SessionStartData).AutoTier + case "session.resume": + actual = event.Data.(*SessionResumeData).AutoTier + } + if tier == "" { + if actual != nil { + t.Fatalf("expected omitted autoTier, got %v", *actual) + } + } else if actual == nil || *actual != tier { + t.Fatalf("expected autoTier %q, got %v", tier, actual) + } + }) + } + } +} + func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) { var event SessionEvent if err := json.Unmarshal([]byte(`{ diff --git a/go/types.go b/go/types.go index 3577731187..09bc281f22 100644 --- a/go/types.go +++ b/go/types.go @@ -2231,6 +2231,18 @@ func (p ProviderConfig) MarshalJSON() ([]byte, error) { return json.Marshal(aux) } +// AutoTier selects the routing tier for model "auto" with V2 Auto. +type AutoTier = rpc.AutoTier + +const ( + // AutoTierEfficiency selects the efficiency routing tier. + AutoTierEfficiency = rpc.AutoTierEfficiency + // AutoTierBalance selects the balance routing tier. + AutoTierBalance = rpc.AutoTierBalance + // AutoTierIntelligence selects the intelligence routing tier. + AutoTierIntelligence = rpc.AutoTierIntelligence +) + // CapiSessionOptions configures provider-scoped Copilot API (CAPI) session behavior. // // WebSocket transport is the default for the CAPI Responses API whenever the @@ -2245,6 +2257,14 @@ type CapiSessionOptions struct { // WebSocket transport. Enabled by default when the model advertises // ws:/responses support; set to Bool(false) to force HTTP Responses transport. EnableWebSocketResponses *bool `json:"enableWebSocketResponses,omitempty"` + + // AutoTier selects the routing tier for model "auto" with V2 Auto. + // Requires a runtime that supports Auto tiers; it has no effect outside V2 Auto. + // When unset, the runtime uses its default on create and preserves the + // persisted or current tier on resume. An explicit tier overrides the + // persisted tier on a cold resume; a conflicting tier on a resident + // session resume is rejected by the runtime. + AutoTier AutoTier `json:"autoTier,omitempty"` } // AzureProviderOptions contains Azure-specific provider configuration diff --git a/go/zsession_events.go b/go/zsession_events.go index 8731009064..bdd9c918f8 100644 --- a/go/zsession_events.go +++ b/go/zsession_events.go @@ -71,7 +71,6 @@ type ( AutoModeSwitchResponse = rpc.AutoModeSwitchResponse AutopilotObjectiveChangedOperation = rpc.AutopilotObjectiveChangedOperation AutopilotObjectiveChangedStatus = rpc.AutopilotObjectiveChangedStatus - AutoTier = rpc.AutoTier BinaryAssetReference = rpc.BinaryAssetReference BinaryAssetReferenceType = rpc.BinaryAssetReferenceType BinaryAssetType = rpc.BinaryAssetType @@ -476,9 +475,6 @@ const ( AutopilotObjectiveChangedStatusCapReached = rpc.AutopilotObjectiveChangedStatusCapReached AutopilotObjectiveChangedStatusCompleted = rpc.AutopilotObjectiveChangedStatusCompleted AutopilotObjectiveChangedStatusPaused = rpc.AutopilotObjectiveChangedStatusPaused - AutoTierBalance = rpc.AutoTierBalance - AutoTierEfficiency = rpc.AutoTierEfficiency - AutoTierIntelligence = rpc.AutoTierIntelligence BinaryAssetReferenceTypeImage = rpc.BinaryAssetReferenceTypeImage BinaryAssetReferenceTypeResource = rpc.BinaryAssetReferenceTypeResource BinaryAssetTypeImage = rpc.BinaryAssetTypeImage diff --git a/java/README.md b/java/README.md index f3ba3f63a0..f01911c159 100644 --- a/java/README.md +++ b/java/README.md @@ -341,6 +341,34 @@ Chain fluent modifiers to set tool options: For design context and decision rationale, see [ADR-006](docs/adr/adr-006-tool-definition-inline.md). +## Auto routing tiers + +Use `CapiSessionOptions.setAutoTier(...)` to select `AutoTier.EFFICIENCY`, +`AutoTier.BALANCE`, or `AutoTier.INTELLIGENCE`. This option is meaningful only +with model `auto` (Auto mode V2). +It requires a runtime version that supports `capi.autoTier`. + +```java +import com.github.copilot.rpc.AutoTier; +import com.github.copilot.rpc.CapiSessionOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +var config = new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setModel("auto") + .setCapi(new CapiSessionOptions().setAutoTier(AutoTier.BALANCE)); +``` + +The same options work with `ResumeSessionConfig.setCapi(...)` and can be combined +with `setEnableWebSocketResponses(false)`. The SDK omits an unset (`null`) tier: +the runtime chooses its default on create and preserves the persisted/current +tier on resume. An explicit tier overrides the persisted tier on cold resume; +the runtime rejects a conflicting tier when the session is already resident +in memory. The SDK does not choose a default or manage tier persistence. +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) +for the lifecycle rules. + ## Session Store `enableSessionStore` on `SessionConfig` enables the cross-session store for search and retrieval across sessions. When unset in the default `CopilotClientMode.COPILOT_CLI` mode, the runtime default applies (enabled). In `CopilotClientMode.EMPTY` mode, defaults to disabled. diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/AutoTier.java b/java/sdk/src/main/java/com/github/copilot/rpc/AutoTier.java new file mode 100644 index 0000000000..f9117abfb2 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/AutoTier.java @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Routing tier for the {@code auto} model with Auto mode V2. + * + * @see CapiSessionOptions#setAutoTier(AutoTier) + */ +public enum AutoTier { + + /** Prioritize efficiency. */ + EFFICIENCY("efficiency"), + + /** Balance efficiency and intelligence. */ + BALANCE("balance"), + + /** Prioritize intelligence. */ + INTELLIGENCE("intelligence"); + + private final String value; + + AutoTier(String value) { + this.value = value; + } + + /** + * Returns the JSON value for this routing tier. + * + * @return the string value used in JSON serialization + */ + @JsonValue + public String getValue() { + return value; + } + + /** + * Deserializes a JSON string into its routing tier. + * + * @param value + * the JSON string value + * @return the matching tier, or {@code null} if value is {@code null} + * @throws IllegalArgumentException + * if the value does not match a known routing tier + */ + @JsonCreator + public static AutoTier fromValue(String value) { + if (value == null) { + return null; + } + for (AutoTier tier : values()) { + if (tier.value.equals(value)) { + return tier; + } + } + throw new IllegalArgumentException("Unknown AutoTier value: " + value); + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java b/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java index d94d59f67b..e401762302 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CapiSessionOptions.java @@ -29,9 +29,40 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class CapiSessionOptions { + @JsonProperty("autoTier") + private AutoTier autoTier; + @JsonProperty("enableWebSocketResponses") private Boolean enableWebSocketResponses; + /** + * Gets the routing tier for the {@code auto} model (Auto mode V2). + * + * @return the explicit tier, or {@code null} to leave tier selection to the + * runtime + */ + public AutoTier getAutoTier() { + return autoTier; + } + + /** + * Sets the routing tier, meaningful only with model {@code auto} (Auto mode + * V2). Requires a runtime version that supports {@code capi.autoTier}. + *

+ * When omitted, the runtime chooses its default on create and preserves the + * persisted or current tier on resume. An explicit tier overrides the persisted + * tier on cold resume; the runtime rejects a conflicting tier when resuming a + * session already resident in memory. + * + * @param autoTier + * the routing tier, or {@code null} to omit it from the request + * @return this config for method chaining + */ + public CapiSessionOptions setAutoTier(AutoTier autoTier) { + this.autoTier = autoTier; + return this; + } + /** * Gets whether CAPI Responses API WebSocket transport is enabled. * diff --git a/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java b/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java index 17e8f131f7..dccb4e9add 100644 --- a/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CapiSessionOptionsTest.java @@ -9,12 +9,16 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.rpc.AutoTier; import com.github.copilot.rpc.CapiSessionOptions; import com.github.copilot.rpc.ResumeSessionConfig; import com.github.copilot.rpc.SessionConfig; @@ -29,6 +33,7 @@ void defaultsAreNull() { var capi = new CapiSessionOptions(); assertNull(capi.getEnableWebSocketResponses()); + assertNull(capi.getAutoTier()); } @Test @@ -37,6 +42,8 @@ void fluentSetterReturnsSameInstance() { assertSame(capi, capi.setEnableWebSocketResponses(true)); assertEquals(Boolean.TRUE, capi.getEnableWebSocketResponses()); + assertSame(capi, capi.setAutoTier(AutoTier.BALANCE)); + assertEquals(AutoTier.BALANCE, capi.getAutoTier()); } @Test @@ -46,6 +53,7 @@ void serializesEnableWebSocketResponses() { JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); assertTrue(json.get("enableWebSocketResponses").asBoolean()); + assertTrue(json.path("autoTier").isMissingNode()); } @Test @@ -55,6 +63,44 @@ void omitsUnsetEnableWebSocketResponses() { JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); assertTrue(json.path("enableWebSocketResponses").isMissingNode()); + assertTrue(json.path("autoTier").isMissingNode()); + assertEquals(0, json.size()); + } + + @ParameterizedTest + @CsvSource({"EFFICIENCY,efficiency", "BALANCE,balance", "INTELLIGENCE,intelligence"}) + void autoTierCanonicalValuesRoundTripAndForward(AutoTier tier, String value) throws Exception { + var mapper = JsonRpcClient.getObjectMapper(); + var capi = new CapiSessionOptions().setAutoTier(tier); + JsonNode json = mapper.valueToTree(capi); + assertEquals(value, json.get("autoTier").asText()); + assertEquals(1, json.size()); + assertEquals(tier, mapper.treeToValue(json, CapiSessionOptions.class).getAutoTier()); + + capi.setEnableWebSocketResponses(false); + var create = SessionRequestBuilder.buildCreateRequest(new SessionConfig().setModel("auto").setCapi(capi), + "session-1"); + var resume = SessionRequestBuilder.buildResumeRequest("session-1", new ResumeSessionConfig().setCapi(capi)); + for (Object request : new Object[]{create, resume}) { + JsonNode requestJson = mapper.valueToTree(request); + assertEquals(value, requestJson.get("capi").get("autoTier").asText()); + assertFalse(requestJson.get("capi").get("enableWebSocketResponses").asBoolean()); + assertEquals(2, requestJson.get("capi").size()); + } + } + + @Test + void autoTierRejectsNoncanonicalValues() { + for (String value : new String[]{"balanced", "Balance", "unknown"}) { + assertThrows(IllegalArgumentException.class, () -> AutoTier.fromValue(value)); + } + assertNull(AutoTier.fromValue(null)); + } + + @Test + void clearingAutoTierOmitsIt() { + var capi = new CapiSessionOptions().setAutoTier(AutoTier.BALANCE).setAutoTier(null); + JsonNode json = JsonRpcClient.getObjectMapper().valueToTree(capi); assertEquals(0, json.size()); } @@ -67,6 +113,7 @@ void createRequestIncludesCapiWhenSet() { assertNotNull(request.getCapi()); assertTrue(json.get("capi").get("enableWebSocketResponses").asBoolean()); + assertTrue(json.get("capi").path("autoTier").isMissingNode()); } @Test @@ -89,6 +136,7 @@ void resumeRequestIncludesCapiWhenSet() { assertNotNull(request.getCapi()); assertTrue(json.get("capi").get("enableWebSocketResponses").asBoolean()); + assertTrue(json.get("capi").path("autoTier").isMissingNode()); } @Test diff --git a/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java new file mode 100644 index 0000000000..5213cdbb8d --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/SessionAutoTierEventTest.java @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * 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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.github.copilot.generated.AutoTier; +import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.SessionResumeEvent; +import com.github.copilot.generated.SessionStartEvent; + +/** + * Verifies auto routing preferences on generated session lifecycle events. + */ +class SessionAutoTierEventTest { + + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + + @ParameterizedTest + @CsvSource({"session.start,EFFICIENCY,efficiency", "session.start,BALANCE,balance", + "session.start,INTELLIGENCE,intelligence", "session.resume,EFFICIENCY,efficiency", + "session.resume,BALANCE,balance", "session.resume,INTELLIGENCE,intelligence"}) + void canonicalAutoTierRoundTrips(String type, AutoTier tier, String value) throws Exception { + String json = """ + {"type":"%s","data":{"selectedModel":"auto","autoTier":"%s"}} + """.formatted(type, value); + + var event = MAPPER.readValue(json, SessionEvent.class); + assertEquals(tier, autoTier(event, type)); + String serialized = MAPPER.writeValueAsString(event); + assertEquals(value, MAPPER.readTree(serialized).path("data").path("autoTier").asText()); + assertEquals(tier, autoTier(MAPPER.readValue(serialized, SessionEvent.class), type)); + } + + @ParameterizedTest + @ValueSource(strings = {"session.start", "session.resume"}) + void missingOrNullAutoTierRemainsOptional(String type) throws Exception { + for (String data : new String[]{"{}", "{\"autoTier\":null}"}) { + String json = """ + {"type":"%s","data":%s} + """.formatted(type, data); + + var event = MAPPER.readValue(json, SessionEvent.class); + assertNull(autoTier(event, type)); + var serialized = MAPPER.readTree(MAPPER.writeValueAsString(event)); + assertFalse(serialized.path("data").has("autoTier")); + } + } + + private static AutoTier autoTier(SessionEvent event, String type) { + if ("session.start".equals(type)) { + return assertInstanceOf(SessionStartEvent.class, event).getData().autoTier(); + } + return assertInstanceOf(SessionResumeEvent.class, event).getData().autoTier(); + } +} diff --git a/nodejs/README.md b/nodejs/README.md index 5886df234f..57a8bf484b 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -132,6 +132,7 @@ Create a new conversation session. - `sessionId?: string` - Custom session ID. - `model?: string` - Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** +- `capi?: CapiSessionOptions` - Copilot API options. With `model: "auto"`, set `autoTier` to `"efficiency"`, `"balance"`, or `"intelligence"` to choose a routing preference. Requires a runtime with Auto tier support and V2 Auto routing. Omission preserves default behavior. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for resume semantics. - `reasoningEffort?: "low" | "medium" | "high" | "xhigh" | "max"` - Reasoning effort level for models that support it. Use `listModels()` to check which models support this option. - `tools?: Tool[]` - Custom tools exposed to the CLI. Tools without `handler` are declaration-only and must be resolved via pending tool-call RPCs. - `systemMessage?: SystemMessageConfig` - System message customization (see below) diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 8b9095e0ba..11e742bee7 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -120,6 +120,7 @@ export type { ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + AutoTier, CapiSessionOptions, ModelCapabilities, ModelCapabilitiesOverride, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 85c77f8340..cd211fd9f5 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -11,6 +11,7 @@ import type { Canvas } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; import type { + AutoTier, PermissionRequest as GeneratedPermissionRequest, PermissionRequestedData as GeneratedPermissionRequestedData, PermissionRequestedEvent as GeneratedPermissionRequestedEvent, @@ -72,7 +73,7 @@ export type { export type SessionEvent = | Exclude | PermissionRequestedEvent; -export type { ReasoningSummary } from "./generated/session-events.js"; +export type { AutoTier, ReasoningSummary } from "./generated/session-events.js"; export type { SessionFsProvider } from "./sessionFsProvider.js"; export { createSessionFsAdapter } from "./sessionFsProvider.js"; export type { SessionFsFileInfo } from "./sessionFsProvider.js"; @@ -2129,6 +2130,17 @@ export interface FactoryMeta { * provider-level choices are conceptually per-provider rather than global. */ export interface CapiSessionOptions { + /** + * Routing preference used when the session model is `auto`. + * Requires a runtime with Auto tier support and V2 Auto routing. + * + * When omitted on create, the runtime uses its default routing behavior. + * The runtime persists this preference across cold resume; an explicit tier + * on cold resume overrides the persisted value. For an already-resident + * session, omission preserves the current tier and a different tier is rejected. + */ + autoTier?: AutoTier; + /** * Whether to use the WebSocket transport for the CAPI Responses API. * diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a08e4a6e5a..829d69f2e5 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -12,6 +12,7 @@ import { createCanvas, DisableBypassPermissionsModes, RuntimeConnection, + type CapiSessionOptions, type GitHubTelemetryNotification, type ManagedSettings, type ModelInfo, @@ -1364,37 +1365,50 @@ describe("CopilotClient", () => { expect(resumePayload.featureFlags).toEqual(featureFlags); }); - it("forwards capi options in session.create and session.resume", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => stopClient(client)); + it.each([ + undefined, + {}, + { enableWebSocketResponses: false }, + { enableWebSocketResponses: true }, + { autoTier: "efficiency" }, + { autoTier: "balance" }, + { autoTier: "intelligence" }, + { autoTier: "balance", enableWebSocketResponses: false }, + ] satisfies (CapiSessionOptions | undefined)[])( + "forwards capi options %j in session.create and session.resume", + async (capi) => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); - 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 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, - capi: { enableWebSocketResponses: false }, - }); - await client.resumeSession(session.sessionId, { - onPermissionRequest: approveAll, - capi: { enableWebSocketResponses: false }, - }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "auto", + capi, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + capi, + }); - 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.capi).toEqual({ enableWebSocketResponses: false }); - expect(resumePayload.capi).toEqual({ enableWebSocketResponses: false }); - }); + 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(JSON.parse(JSON.stringify(createPayload)).capi).toEqual(capi); + expect(JSON.parse(JSON.stringify(resumePayload)).capi).toEqual(capi); + } + ); it("forwards pluginDirectories and largeOutput in session.create and session.resume", async () => { const client = new CopilotClient(); diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index 5c41f2216a..93edebfc80 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -21,6 +21,8 @@ import type { FactoryAgentOptions as WireFactoryAgentOptions } from "../src/gene import type { // The aggregate union; must still resolve via the package root. SessionEvent, + AutoTier, + CapiSessionOptions, PermissionRequest, PermissionRequestedData, PermissionRequestedEvent, @@ -128,6 +130,32 @@ type _PermissionRequestedEventStaysAlignedWithSessionEventUnion = _AssertEqual< const _permissionRequestedEventAlignmentCheck: _PermissionRequestedEventStaysAlignedWithSessionEventUnion = true; describe("Session event type exports (#1156)", () => { + it.each(["efficiency", "balance", "intelligence", undefined] satisfies ( + | AutoTier + | undefined + )[])("exposes Auto tier %s on start and resume data", (autoTier) => { + const start: StartData = { + copilotVersion: "1.0.82-1", + producer: "copilot-agent", + sessionId: "session-1", + startTime: "2026-08-28T00:00:00Z", + version: 1, + autoTier, + }; + const resume: ResumeData = { + eventCount: 1, + resumeTime: "2026-08-28T00:01:00Z", + autoTier, + }; + const capi: CapiSessionOptions = { autoTier: start.autoTier }; + expect(capi.autoTier).toBe(autoTier); + expect(resume.autoTier).toBe(autoTier); + if (autoTier === undefined) { + expect(JSON.parse(JSON.stringify(start))).not.toHaveProperty("autoTier"); + expect(JSON.parse(JSON.stringify(resume))).not.toHaveProperty("autoTier"); + } + }); + it("exposes the headline ToolExecutionStartData type with a usable shape", () => { // This is the specific type called out in issue #1156. The annotation // is the compile-time API-surface check; these assertions only validate diff --git a/python/README.md b/python/README.md index 5cb1dc03c9..359026df41 100644 --- a/python/README.md +++ b/python/README.md @@ -278,6 +278,7 @@ finally: These are passed as keyword arguments to `create_session()`: - `model` (str): Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** +- `capi` (CapiSessionOptions): Copilot API options. With `model="auto"`, set `auto_tier` to `"efficiency"`, `"balance"`, or `"intelligence"` to choose a routing preference. Requires a runtime with Auto tier support and V2 Auto routing. Omission preserves default behavior. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for resume semantics. - `reasoning_effort` (str): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh", "max"). Use `list_models()` to check which models support this option. - `session_id` (str): Custom session ID - `tools` (list): Custom tools exposed to the CLI. Tools with `handler=None` are declaration-only and must be resolved via pending tool-call RPCs. diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 4b3b9901ed..5325b937c5 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -30,6 +30,7 @@ ) from .client import ( AskUserVariant, + AutoTier, CapiSessionOptions, ChildProcessRuntimeConnection, CloudSessionOptions, @@ -231,6 +232,7 @@ "AutoModeSwitchRequest", "AutoModeSwitchResponse", "AskUserVariant", + "AutoTier", "BUILTIN_TOOLS_ISOLATED", "CanvasAction", "CanvasDeclaration", diff --git a/python/copilot/client.py b/python/copilot/client.py index e50e5fa307..a15c1a569e 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -263,9 +263,23 @@ def _exp_assignment_response_to_dict( return wire +AutoTier = Literal["efficiency", "balance", "intelligence"] +"""Routing preference used when the session model is ``auto``.""" + + class CapiSessionOptions(TypedDict, total=False): """Provider-scoped Copilot API (CAPI) session options.""" + auto_tier: AutoTier + """Routing preference used when the session model is ``auto``. + + Requires a runtime with Auto tier support and V2 Auto routing. When omitted + on create, the runtime uses its default routing behavior. The runtime persists + this preference across cold resume; an explicit tier on cold resume overrides + the persisted value. For an already-resident session, omission preserves the + current tier and a different tier is rejected. + """ + enable_web_socket_responses: bool """Whether to use WebSocket transport for the CAPI Responses API. @@ -292,6 +306,8 @@ def _cloud_session_options_to_dict(options: CloudSessionOptions) -> dict[str, An def _capi_session_options_to_wire(options: CapiSessionOptions) -> dict[str, Any]: wire: dict[str, Any] = {} + if "auto_tier" in options: + wire["autoTier"] = options["auto_tier"] if "enable_web_socket_responses" in options: wire["enableWebSocketResponses"] = options["enable_web_socket_responses"] return wire @@ -2298,7 +2314,9 @@ async def create_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. - capi: CAPI provider-scoped options. WebSocket transport is the + capi: CAPI provider-scoped options. Set ``auto_tier`` to ``efficiency``, + ``balance``, or ``intelligence`` to select an Auto routing preference + on a runtime with Auto tier support. WebSocket transport is the default for the CAPI Responses API whenever the model advertises the ``ws:/responses`` endpoint. Set ``enable_web_socket_responses=False`` to force the HTTP @@ -3076,7 +3094,10 @@ async def resume_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. - capi: CAPI provider-scoped options. WebSocket transport is the + capi: CAPI provider-scoped options. Omit ``auto_tier`` to preserve the + current or persisted Auto routing preference. An explicit tier + overrides it on cold resume, but cannot change it on an + already-resident session. WebSocket transport is the default for the CAPI Responses API whenever the model advertises the ``ws:/responses`` endpoint. Set ``enable_web_socket_responses=False`` to force the HTTP diff --git a/python/test_client.py b/python/test_client.py index e475dba0e2..d1c6ebe1d7 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1147,7 +1147,56 @@ async def mock_request(method, params, **kwargs): await client.force_stop() @pytest.mark.asyncio - async def test_create_and_resume_session_forward_capi_options(self): + @pytest.mark.parametrize( + ("create_capi", "resume_capi", "expected_create", "expected_resume"), + [ + (None, None, None, None), + ({}, {}, {}, {}), + ( + {"enable_web_socket_responses": False}, + {"enable_web_socket_responses": True}, + {"enableWebSocketResponses": False}, + {"enableWebSocketResponses": True}, + ), + ( + {"enable_web_socket_responses": True}, + {"enable_web_socket_responses": False}, + {"enableWebSocketResponses": True}, + {"enableWebSocketResponses": False}, + ), + ( + {"auto_tier": "efficiency"}, + {"auto_tier": "efficiency"}, + {"autoTier": "efficiency"}, + {"autoTier": "efficiency"}, + ), + ( + {"auto_tier": "balance"}, + {"auto_tier": "balance"}, + {"autoTier": "balance"}, + {"autoTier": "balance"}, + ), + ( + {"auto_tier": "intelligence"}, + {"auto_tier": "intelligence"}, + {"autoTier": "intelligence"}, + {"autoTier": "intelligence"}, + ), + ( + {"auto_tier": "balance", "enable_web_socket_responses": False}, + {"auto_tier": "balance", "enable_web_socket_responses": True}, + {"autoTier": "balance", "enableWebSocketResponses": False}, + {"autoTier": "balance", "enableWebSocketResponses": True}, + ), + ], + ) + async def test_create_and_resume_session_forward_capi_options( + self, + create_capi: CapiSessionOptions | None, + resume_capi: CapiSessionOptions | None, + expected_create: dict[str, object] | None, + expected_resume: dict[str, object] | None, + ): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: @@ -1164,11 +1213,9 @@ async def mock_request(method, params, **kwargs): return {} client._client.request = mock_request - create_capi: CapiSessionOptions = {"enable_web_socket_responses": False} - resume_capi: CapiSessionOptions = {"enable_web_socket_responses": True} - session = await client.create_session( on_permission_request=PermissionHandler.approve_all, + model="auto", capi=create_capi, ) await client.resume_session( @@ -1177,12 +1224,14 @@ async def mock_request(method, params, **kwargs): capi=resume_capi, ) - assert captured["session.create"]["capi"] == { - "enableWebSocketResponses": False, - } - assert captured["session.resume"]["capi"] == { - "enableWebSocketResponses": True, - } + for method, expected in ( + ("session.create", expected_create), + ("session.resume", expected_resume), + ): + if expected is None: + assert "capi" not in captured[method] + else: + assert captured[method]["capi"] == expected finally: await client.force_stop() diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index 2e8015a97d..00cad43217 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -14,6 +14,7 @@ from copilot.session_events import ( AttachmentGitHubReferenceType, + AutoTier, Data, ElicitationCompletedAction, ElicitationRequestedMode, @@ -24,6 +25,8 @@ PermissionRequestMemoryAction, SessionEventType, SessionManagedSettingsResolvedData, + SessionResumeData, + SessionStartData, SessionTaskCompleteData, UserMessageAgentMode, session_event_from_dict, @@ -34,6 +37,40 @@ class TestEventForwardCompatibility: """Test forward compatibility for unknown event types.""" + @pytest.mark.parametrize("event_type", ["session.start", "session.resume"]) + @pytest.mark.parametrize("tier", ["efficiency", "balance", "intelligence", None]) + def test_auto_tier_lifecycle_events_round_trip(self, event_type, tier): + timestamp = "2026-08-28T00:00:00Z" + data = ( + { + "copilotVersion": "1.0.82-1", + "producer": "copilot-agent", + "sessionId": str(uuid4()), + "startTime": timestamp, + "version": 1, + } + if event_type == "session.start" + else {"eventCount": 1, "resumeTime": timestamp} + ) + if tier is not None: + data["autoTier"] = tier + event = session_event_from_dict( + { + "id": str(uuid4()), + "timestamp": timestamp, + "parentId": None, + "type": event_type, + "data": data, + } + ) + assert isinstance(event.data, (SessionStartData, SessionResumeData)) + assert event.data.auto_tier == (AutoTier(tier) if tier is not None else None) + serialized = session_event_to_dict(event)["data"] + if tier is None: + assert "autoTier" not in serialized + else: + assert serialized["autoTier"] == tier + def test_session_usage_info_is_recognized(self): """The session.usage_info event type should be in the enum.""" assert SessionEventType.SESSION_USAGE_INFO.value == "session.usage_info" diff --git a/rust/README.md b/rust/README.md index c05746a30e..b9a1f5b1bc 100644 --- a/rust/README.md +++ b/rust/README.md @@ -362,6 +362,30 @@ provider errors, and invalid token responses reject that operation instead of falling back to ambient authentication. Idle sessions refresh only before their next credential-consuming operation; there is no background refresh timer. +### Auto routing tiers + +Use `CapiSessionOptions::with_auto_tier` to select `AutoTier::Efficiency`, +`AutoTier::Balance`, or `AutoTier::Intelligence`. This option is meaningful only +with model `auto` (Auto mode V2). +It requires a runtime version that supports `capi.autoTier`. + +```rust +use github_copilot_sdk::{AutoTier, CapiSessionOptions, SessionConfig}; + +let config = SessionConfig::default() + .with_model("auto") + .with_capi(CapiSessionOptions::new().with_auto_tier(AutoTier::Balance)); +``` + +The same options work with `ResumeSessionConfig::with_capi` and can be combined +with `with_enable_web_socket_responses(false)`. The SDK omits an unset tier: +the runtime chooses its default on create and preserves the persisted/current +tier on resume. An explicit tier overrides the persisted tier on cold resume; +the runtime rejects a conflicting tier when the session is already resident +in memory. The SDK does not choose a default or manage tier persistence. +See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) +for the lifecycle rules. + ### Session Hooks Hooks intercept CLI behavior at lifecycle points — tool use, prompt submission, session start/end, and errors. Install a `SessionHooks` impl with [`SessionConfig::with_hooks`] — the SDK auto-enables `hooks` in `SessionConfig` when one is set. diff --git a/rust/src/types.rs b/rust/src/types.rs index 89906f9627..ee3ac3df26 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -21,6 +21,8 @@ pub use crate::copilot_request_handler::{ CopilotWebSocketResponse, WebSocketTransform, forward_http, }; use crate::generated::api_types::{CurrentToolMetadata, OpenCanvasInstance}; +/// Routing tier for the `auto` model with Auto mode V2. +pub use crate::generated::session_events::AutoTier; use crate::generated::session_events::ReasoningSummary; /// Context window tier for models that support tiered context windows. pub use crate::generated::session_events::{ContextTier, SessionLimitsConfig}; @@ -1417,6 +1419,16 @@ impl ProviderConfig { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct CapiSessionOptions { + /// Routing tier, meaningful only with model `auto` (Auto mode V2). + /// Requires a runtime version that supports `capi.autoTier`. + /// + /// When omitted, the runtime chooses its default on create and preserves + /// the persisted or current tier on resume. An explicit tier overrides the + /// persisted tier on cold resume; the runtime rejects a conflicting tier + /// when resuming a session already resident in memory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, + /// Whether to use WebSocket transport for CAPI Responses API calls. /// /// When `Some(false)`, the runtime uses HTTP Responses transport even if @@ -1432,6 +1444,12 @@ impl CapiSessionOptions { Self::default() } + /// Set the routing tier for the `auto` model (Auto mode V2). + pub fn with_auto_tier(mut self, auto_tier: AutoTier) -> Self { + self.auto_tier = Some(auto_tier); + self + } + /// Set whether to use WebSocket transport for CAPI Responses API calls. pub fn with_enable_web_socket_responses(mut self, enable: bool) -> Self { self.enable_web_socket_responses = Some(enable); @@ -6034,9 +6052,9 @@ mod tests { use super::{ AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, - AttachmentSelectionRange, AzureProviderOptions, CapiSessionOptions, ConnectionState, - CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, ExpConfigEntry, - ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, + AttachmentSelectionRange, AutoTier, AzureProviderOptions, CapiSessionOptions, + ConnectionState, CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, + ExpConfigEntry, ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, PermissionResponseCapability, ProviderConfig, ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, @@ -7322,6 +7340,56 @@ mod tests { let unset = CapiSessionOptions::new(); let wire_unset = serde_json::to_value(&unset).unwrap(); assert!(wire_unset.get("enableWebSocketResponses").is_none()); + assert!(wire_unset.get("autoTier").is_none()); + assert_eq!(wire_unset, json!({})); + } + + #[test] + fn capi_auto_tier_canonical_values_round_trip_and_forward() { + for (tier, value) in [ + (AutoTier::Efficiency, "efficiency"), + (AutoTier::Balance, "balance"), + (AutoTier::Intelligence, "intelligence"), + ] { + let exported: crate::AutoTier = tier.clone(); + let capi = CapiSessionOptions::new().with_auto_tier(exported); + assert_eq!(capi.auto_tier, Some(tier)); + assert_eq!( + serde_json::to_value(&capi).unwrap(), + json!({"autoTier": value}) + ); + assert_eq!( + serde_json::from_value::(json!({"autoTier": value})).unwrap(), + capi + ); + + let capi = capi.with_enable_web_socket_responses(false); + let expected = json!({"autoTier": value, "enableWebSocketResponses": false}); + let (create, _) = SessionConfig::default() + .with_model("auto") + .with_capi(capi.clone()) + .into_wire(Some(SessionId::from("capi-create"))) + .unwrap(); + assert_eq!(serde_json::to_value(create).unwrap()["capi"], expected); + + let (resume, _) = ResumeSessionConfig::new(SessionId::from("capi-resume")) + .with_capi(capi) + .into_wire() + .unwrap(); + assert_eq!(serde_json::to_value(resume).unwrap()["capi"], expected); + } + } + + #[test] + fn capi_auto_tier_accepts_unknown_values_for_forward_compatibility() { + for value in ["balanced", "Balance", "unknown"] { + assert_eq!( + serde_json::from_value::(json!(value)).unwrap(), + AutoTier::Unknown + ); + } + let capi: CapiSessionOptions = serde_json::from_value(json!({})).unwrap(); + assert_eq!(capi.auto_tier, None); } #[test] diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 9b86b1367a..8ed40e7c76 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -3,11 +3,53 @@ #![allow(clippy::unwrap_used)] +use github_copilot_sdk::AutoTier; use github_copilot_sdk::rpc::{ Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, TasksStartAgentRequest, }; -use github_copilot_sdk::session_events::{PermissionRequest, PermissionRequestedData}; +use github_copilot_sdk::session_events::{ + PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent, +}; + +#[test] +fn session_events_deserialize_auto_tier() { + for event_type in ["session.start", "session.resume"] { + for (tier, wire_tier) in [ + (Some(AutoTier::Efficiency), Some("efficiency")), + (Some(AutoTier::Balance), Some("balance")), + (Some(AutoTier::Intelligence), Some("intelligence")), + (None, None), + ] { + let mut wire = serde_json::json!({ + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-08-28T00:00:00Z", + "parentId": null, + "type": event_type, + "data": { + "sessionId": "test-session", "version": 1, + "producer": "copilot", "copilotVersion": "1.0.82-1", + "startTime": "2026-08-28T00:00:00Z", + "resumeTime": "2026-08-28T00:00:00Z", "eventCount": 1 + } + }); + if let Some(wire_tier) = wire_tier { + wire["data"]["autoTier"] = serde_json::json!(wire_tier); + } + let event: TypedSessionEvent = serde_json::from_value(wire).unwrap(); + let actual: Option = match event.payload { + SessionEventData::SessionStart(data) if event_type == "session.start" => { + data.auto_tier + } + SessionEventData::SessionResume(data) if event_type == "session.resume" => { + data.auto_tier + } + _ => panic!("expected {event_type}"), + }; + assert_eq!(actual, tier); + } + } +} #[test] fn extension_running_has_expected_status_and_source() { From 2980c7828d35754bfc2b334831efec309ab8a2eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:41:08 +0000 Subject: [PATCH 21/21] Update @github/copilot to 1.0.83-1 (#2467) * Update @github/copilot to 1.0.83-1 - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code * Fix compatibility tests for Copilot schema update Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Stephen Toub Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 64 +++++++++++++++-- dotnet/src/Generated/SessionEvents.cs | 47 ++++++++++-- go/rpc/zrpc.go | 48 ++++++++++++- go/rpc/zrpc_encoding.go | 19 +++++ go/rpc/zsession_encoding.go | 6 ++ go/rpc/zsession_events.go | 26 +++++-- go/zsession_events.go | 3 + java/pom.xml | 2 +- java/scripts/codegen/package-lock.json | 72 +++++++++---------- java/scripts/codegen/package.json | 2 +- .../AssistantMessageReasoningBlocks.java | 2 +- .../ManagedSettingsResolvedSource.java | 2 + .../copilot/generated/SessionEvent.java | 2 + .../SessionManagedSettingsResolvedEvent.java | 6 +- .../SessionModeNoticeDeliveredEvent.java | 43 +++++++++++ .../rpc/CatalogNetworkFailureError.java | 7 ++ .../rpc/CatalogNetworkFailureReason.java | 6 ++ .../generated/rpc/CatalogSearchParams.java | 2 +- .../generated/rpc/FactoryRunResult.java | 4 +- .../generated/rpc/ModelBillingPromo.java | 4 +- .../rpc/SessionFactoryCancelResult.java | 4 +- .../rpc/SessionFactoryGetRunResult.java | 4 +- .../rpc/SessionFactoryRunFromToolResult.java | 4 +- .../rpc/SessionFactoryRunResult.java | 4 +- ...SessionModelApplyStartupOverlayParams.java | 2 + .../rpc/SlashCommandCompletedResult.java | 7 ++ .../rpc/GeneratedRpcRecordsCoverageTest.java | 3 +- nodejs/package-lock.json | 54 +++++++------- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/rpc.ts | 37 +++++++++- nodejs/src/generated/session-events.ts | 55 ++++++++++++-- python/copilot/generated/rpc.py | 71 +++++++++++++++--- python/copilot/generated/session_events.py | 40 ++++++++++- python/test_event_forward_compatibility.py | 1 + rust/src/generated/api_types.rs | 50 +++++++++++-- rust/src/generated/session_events.rs | 29 ++++++-- test/harness/package-lock.json | 54 +++++++------- test/harness/package.json | 2 +- 39 files changed, 639 insertions(+), 153 deletions(-) create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/SessionModeNoticeDeliveredEvent.java diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index c7be742ee2..4379a2c122 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -118,6 +118,10 @@ public sealed class ModelBillingPromo ///

Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it when present. [JsonPropertyName("message")] public string? Message { get; set; } + + /// Whether the service asked hosts to give this promotion a prominent surface, such as a dedicated banner, in addition to listing it with the model. `true` requests that surface and `false` asks for the model list only. Absent means the service expressed no preference — for example a response that predates the field — so hosts should apply their own default rather than read it as `false`. + [JsonPropertyName("showBanner")] + public bool? ShowBanner { get; set; } } /// Long context tier pricing (available for models with extended context windows). @@ -1841,6 +1845,11 @@ public partial class McpPlanInstallResultNetworkFailure : McpPlanInstallResult [JsonPropertyName("reason")] public required CatalogNetworkFailureReason Reason { get; set; } + /// Bounded cooldown in seconds before another catalog request should be attempted, when the authority supplied a numeric Retry-After value or the runtime applied its documented fallback. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("retryAfterSeconds")] + public int? RetryAfterSeconds { get; set; } + /// HTTP status code, when the failure was a rejected response. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("statusCode")] @@ -2655,6 +2664,11 @@ public partial class CatalogSearchResultNetworkFailure : CatalogSearchResult [JsonPropertyName("reason")] public required CatalogNetworkFailureReason Reason { get; set; } + /// Bounded cooldown in seconds before another catalog request should be attempted, when the authority supplied a numeric Retry-After value or the runtime applied its documented fallback. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("retryAfterSeconds")] + public int? RetryAfterSeconds { get; set; } + /// HTTP status code, when the failure was a rejected response. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("statusCode")] @@ -2762,7 +2776,7 @@ internal sealed class CatalogSearchRequest [JsonPropertyName("limit")] public int? Limit { get; set; } - /// Free-text search query. Never written to logs or telemetry. + /// Free-text search query. Persisted as tool input for session continuity, but omitted from telemetry. [RegularExpression("\\S")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Safe for generated string properties: JSON Schema minLength/maxLength map to string length validation, not reflection over trimmed Count members")] [MinLength(1)] @@ -6244,6 +6258,7 @@ internal sealed class CanvasProviderUnregisterRequest [JsonDerivedType(typeof(FactoryRunFailureFactoryResumeDeclined), "factory_resume_declined")] [JsonDerivedType(typeof(FactoryRunFailureFactoryDurableFailure), "factory_durable_failure")] [JsonDerivedType(typeof(FactoryRunFailureFactoryAccountingIncomplete), "factory_accounting_incomplete")] +[JsonDerivedType(typeof(FactoryRunFailureFactoryProviderDisconnected), "factory_provider_disconnected")] public partial class FactoryRunFailure { /// The type discriminator. @@ -6329,15 +6344,33 @@ public partial class FactoryRunFailureFactoryAccountingIncomplete : FactoryRunFa public required string RunId { get; set; } } +/// The extension that owns the factory disconnected while the run was executing, so the host halted it. The run's journaled subagent results are preserved so a resume can reuse them. +/// The factory_provider_disconnected variant of . +[Experimental(Diagnostics.Experimental)] +public partial class FactoryRunFailureFactoryProviderDisconnected : FactoryRunFailure +{ + /// + [JsonIgnore] + public override string Type => "factory_provider_disconnected"; + + /// Factory run identifier. + [JsonPropertyName("runId")] + public required string RunId { get; set; } +} + /// Complete current or terminal factory run envelope. [Experimental(Diagnostics.Experimental)] public sealed class FactoryRunResult { + /// One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. + [JsonPropertyName("attempt")] + public long? Attempt { get; set; } + /// Error message for an errored run. [JsonPropertyName("error")] public string? Error { get; set; } - /// Machine-readable failure details for an errored run. + /// Machine-readable failure details for a halted or errored run. [JsonPropertyName("failure")] public FactoryRunFailure? Failure { get; set; } @@ -7479,6 +7512,10 @@ internal sealed class ModelApplyStartupOverlayRequest [JsonPropertyName("deviceManagedModel")] public string? DeviceManagedModel { get; set; } + /// Startup default model from the enterprise policy helper, when configured. Weakest of the managed sources: it applies only when neither device nor server policy names a model, and an explicit user selection still wins. + [JsonPropertyName("policyHelperModel")] + public string? PolicyHelperModel { get; set; } + /// Context tier selected by repository settings, when configured. [JsonPropertyName("repoContextTier")] public string? RepoContextTier { get; set; } @@ -13100,6 +13137,11 @@ public partial class SlashCommandInvocationResultCompleted : SlashCommandInvocat [JsonPropertyName("message")] public string? Message { get; set; } + /// Optional target session mode applied without submitting an agent prompt. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mode")] + public SessionMode? Mode { get; set; } + /// True when the invocation mutated user runtime settings; consumers caching settings should refresh. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("runtimeSettingsChanged")] @@ -20285,7 +20327,16 @@ public CatalogNetworkFailureReason(string value) /// The connection was refused or reset. public static CatalogNetworkFailureReason ConnectionRefused { get; } = new("connection-refused"); - /// The authority returned a status the runtime treats as a failure. + /// The configured proxy returned 407 and requires authentication. + public static CatalogNetworkFailureReason ProxyAuthenticationRequired { get; } = new("proxy-authentication-required"); + + /// The authority rate-limited requests and supplied or implied a bounded cooldown. + public static CatalogNetworkFailureReason RateLimited { get; } = new("rate-limited"); + + /// The authority returned a transient 5xx response. + public static CatalogNetworkFailureReason ServiceUnavailable { get; } = new("service-unavailable"); + + /// The authority returned another status the runtime treats as a failure. public static CatalogNetworkFailureReason HttpStatus { get; } = new("http-status"); /// The response exceeded the permitted size. @@ -30014,7 +30065,7 @@ internal ServerCatalogApi(JsonRpc rpc) /// Requests a bounded catalog search. This host-implemented server method is available through SDK/TUI hosts; standalone and C-ABI runtimes whose host does not implement server-method dispatch return JSON-RPC MethodNotFound. A runtime with search available returns inert candidate summaries, each with an opaque single-use handle scoped to this runtime instance; a runtime without it returns the typed search-unavailable result. Public authorities may be searched anonymously, while an authority that requires credentials yields the typed authentication-required result. All returned text, URLs, and package metadata are untrusted external data and can never trigger instructions, tools, or installation. Read-only: nothing is installed, configured, or persisted. /// Protocol version and capabilities the caller requires. - /// Free-text search query. Never written to logs or telemetry. + /// Free-text search query. Persisted as tool input for session continuity, but omitted from telemetry. /// Maximum number of candidates to return. Defaults to 10 when omitted. /// Restrict results to these candidate kinds. When omitted, every kind the runtime supports is searched. /// The to monitor for cancellation requests. The default is . @@ -31967,6 +32018,7 @@ public async Task SwitchToAsync(string modelId, string? rea /// Resolves and applies organization-managed and repository model overlays. /// Model required by device-managed policy, when configured. /// Model required by server-managed policy, when configured. + /// Startup default model from the enterprise policy helper, when configured. Weakest of the managed sources: it applies only when neither device nor server policy names a model, and an explicit user selection still wins. /// Model selected by repository settings, when configured. /// Reasoning effort selected by repository settings, when configured. /// Context tier selected by repository settings, when configured. @@ -31974,11 +32026,11 @@ public async Task SwitchToAsync(string modelId, string? rea /// Whether the overlay is being applied while resuming a deferred session. /// The to monitor for cancellation requests. The default is . /// The model identifier active on the session after the switch. - internal async Task ApplyStartupOverlayAsync(string? deviceManagedModel = null, string? serverManagedModel = null, string? repoModel = null, string? repoReasoningEffort = null, string? repoContextTier = null, string? cliModel = null, bool? deferredResume = null, CancellationToken cancellationToken = default) + internal async Task ApplyStartupOverlayAsync(string? deviceManagedModel = null, string? serverManagedModel = null, string? policyHelperModel = null, string? repoModel = null, string? repoReasoningEffort = null, string? repoContextTier = null, string? cliModel = null, bool? deferredResume = null, CancellationToken cancellationToken = default) { _session.ThrowIfDisposed(); - var request = new ModelApplyStartupOverlayRequest { SessionId = _session.SessionId, DeviceManagedModel = deviceManagedModel, ServerManagedModel = serverManagedModel, RepoModel = repoModel, RepoReasoningEffort = repoReasoningEffort, RepoContextTier = repoContextTier, CliModel = cliModel, DeferredResume = deferredResume }; + var request = new ModelApplyStartupOverlayRequest { SessionId = _session.SessionId, DeviceManagedModel = deviceManagedModel, ServerManagedModel = serverManagedModel, PolicyHelperModel = policyHelperModel, RepoModel = repoModel, RepoReasoningEffort = repoReasoningEffort, RepoContextTier = repoContextTier, CliModel = cliModel, DeferredResume = deferredResume }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.model.applyStartupOverlay", [request], cancellationToken); } diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 6bfe202bff..170e83027a 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -113,6 +113,7 @@ namespace GitHub.Copilot; [JsonDerivedType(typeof(SessionMcpServerStatusChangedEvent), "session.mcp_server_status_changed")] [JsonDerivedType(typeof(SessionMcpServersLoadedEvent), "session.mcp_servers_loaded")] [JsonDerivedType(typeof(SessionModeChangedEvent), "session.mode_changed")] +[JsonDerivedType(typeof(SessionModeNoticeDeliveredEvent), "session.mode_notice_delivered")] [JsonDerivedType(typeof(SessionModelChangeEvent), "session.model_change")] [JsonDerivedType(typeof(SessionPermissionsChangedEvent), "session.permissions_changed")] [JsonDerivedType(typeof(SessionPlanChangedEvent), "session.plan_changed")] @@ -378,6 +379,19 @@ public sealed partial class SessionModeChangedEvent : SessionEvent public required SessionModeChangedData Data { get; set; } } +/// Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume. +/// Represents the session.mode_notice_delivered event. +public sealed partial class SessionModeNoticeDeliveredEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.mode_notice_delivered"; + + /// The session.mode_notice_delivered event payload. + [JsonPropertyName("data")] + public required SessionModeNoticeDeliveredData Data { get; set; } +} + /// Session limits update details. Null clears the limits. /// Represents the session.session_limits_changed event. public sealed partial class SessionSessionLimitsChangedEvent : SessionEvent @@ -1546,7 +1560,7 @@ public sealed partial class SessionAutoModeResolvedEvent : SessionEvent public required SessionAutoModeResolvedData Data { get; set; } } -/// Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. +/// Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. /// Represents the session.managed_settings_resolved event. [Experimental(Diagnostics.Experimental)] public sealed partial class SessionManagedSettingsResolvedEvent : SessionEvent @@ -2346,6 +2360,19 @@ public sealed partial class SessionModeChangedData public required SessionMode PreviousMode { get; set; } } +/// Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume. +public sealed partial class SessionModeNoticeDeliveredData +{ + /// Model-visible transition notice persisted for a mid-turn delivery. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// Mode established by the delivered transition notice. + [JsonPropertyName("mode")] + public required SessionMode Mode { get; set; } +} + /// Session limits update details. Null clears the limits. public sealed partial class SessionSessionLimitsChangedData { @@ -5410,7 +5437,7 @@ public sealed partial class SessionAutoModeResolvedData public bool? StickyOverride { get; set; } } -/// Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. +/// Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. [Experimental(Diagnostics.Experimental)] public sealed partial class SessionManagedSettingsResolvedData { @@ -5440,6 +5467,11 @@ public sealed partial class SessionManagedSettingsResolvedData [JsonPropertyName("permissionsAllowIntersected")] public bool? PermissionsAllowIntersected { get; set; } + /// Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("policyHelperManaged")] + public bool? PolicyHelperManaged { get; set; } + /// Whether the effective sandbox policy forces the sandbox on *only* because managed policy could not be determined, rather than because the policy requires it. Lets clients tell a user whose `--no-sandbox` was overridden that the sandbox stayed on as a fail-closed fallback, instead of attributing it to an administrator who set no such policy. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("sandboxEnabledByUndeterminedPolicy")] @@ -5454,7 +5486,7 @@ public sealed partial class SessionManagedSettingsResolvedData [JsonPropertyName("settings")] public JsonElement? Settings { get; set; } - /// Channel summary: `server`, `device`, or `client` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. + /// Channel summary: `server`, `device`, `client`, or `policyHelper` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. [JsonPropertyName("source")] public required ManagedSettingsResolvedSource Source { get; set; } } @@ -7068,7 +7100,7 @@ public sealed partial class FusionAttribution [Experimental(Diagnostics.Experimental)] public sealed partial class AssistantMessageReasoningBlocks { - /// Provider-native reasoning content blocks (e.g. Anthropic `thinking` / `redacted_thinking`) preserved verbatim, in order. A single response can carry several, each signed over the content preceding it, so dropping or reordering any of them invalidates the rest. + /// Provider-native reasoning items or content blocks preserved verbatim, in order. A single response can carry several, and provider signatures or identifiers may depend on their exact content and ordering. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("blocks")] public JsonElement[]? Blocks { get; set; } @@ -14833,7 +14865,10 @@ public ManagedSettingsResolvedSource(string value) /// Only session-local SDK-host injection contributed. public static ManagedSettingsResolvedSource Client { get; } = new("client"); - /// More than one channel contributed. Ordinary keys resolve device over server per key, while permissions compose restrictively across all present layers. + /// A policy helper registered by device or server policy contributed. Device registration takes priority when present. + public static ManagedSettingsResolvedSource PolicyHelper { get; } = new("policyHelper"); + + /// More than one channel contributed. Ordinary keys resolve device over server over policy helper per key, while permissions compose restrictively across all present layers. public static ManagedSettingsResolvedSource Mixed { get; } = new("mixed"); /// No managed policy is in force (no channel contributed). @@ -15861,6 +15896,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(SessionMcpServersLoadedEvent))] [JsonSerializable(typeof(SessionModeChangedData))] [JsonSerializable(typeof(SessionModeChangedEvent))] +[JsonSerializable(typeof(SessionModeNoticeDeliveredData))] +[JsonSerializable(typeof(SessionModeNoticeDeliveredEvent))] [JsonSerializable(typeof(SessionModelChangeData))] [JsonSerializable(typeof(SessionModelChangeEvent))] [JsonSerializable(typeof(SessionPermissionsChangedData))] diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 67121c30ed..1b2458590d 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1604,7 +1604,8 @@ type CatalogSearchRequest struct { Kinds []CatalogCandidateKind `json:"kinds,omitzero"` // Maximum number of candidates to return. Defaults to 10 when omitted. Limit *int32 `json:"limit,omitempty"` - // Free-text search query. Never written to logs or telemetry. + // Free-text search query. Persisted as tool input for session continuity, but omitted from + // telemetry. Query string `json:"query"` } @@ -1737,6 +1738,10 @@ type CatalogNetworkFailureError struct { Message string `json:"message"` // Categorised failure, low cardinality so it can be aggregated without carrying a URL. Reason CatalogNetworkFailureReason `json:"reason"` + // Bounded cooldown in seconds before another catalog request should be attempted, when the + // authority supplied a numeric Retry-After value or the runtime applied its documented + // fallback. + RetryAfterSeconds *int32 `json:"retryAfterSeconds,omitempty"` // HTTP status code, when the failure was a rejected response. StatusCode *int32 `json:"statusCode,omitempty"` } @@ -3564,6 +3569,18 @@ func (FactoryRunFailureFactoryLimitReached) Type() FactoryRunFailureType { return FactoryRunFailureTypeFactoryLimitReached } +// The extension that owns the factory disconnected while the run was executing, so the host +// halted it. The run's journaled subagent results are preserved so a resume can reuse them. +type FactoryRunFailureFactoryProviderDisconnected struct { + // Factory run identifier. + RunID string `json:"runId"` +} + +func (FactoryRunFailureFactoryProviderDisconnected) factoryRunFailure() {} +func (FactoryRunFailureFactoryProviderDisconnected) Type() FactoryRunFailureType { + return FactoryRunFailureTypeFactoryProviderDisconnected +} + type FactoryRunFailureFactoryResumeDeclined struct { // Human-readable reason the resume did not proceed. Reason string `json:"reason"` @@ -3609,9 +3626,12 @@ type FactoryRunRequest struct { // Experimental: FactoryRunResult is part of an experimental API and may change or be // removed. type FactoryRunResult struct { + // One-based execution attempt represented by this envelope. Absent before the first attempt + // starts or when returned by an older runtime. + Attempt *int64 `json:"attempt,omitempty"` // Error message for an errored run. Error *string `json:"error,omitempty"` - // Machine-readable failure details for an errored run. + // Machine-readable failure details for a halted or errored run. Failure FactoryRunFailure `json:"failure,omitempty"` // Reason for a halted or cancelled run. Reason *string `json:"reason,omitempty"` @@ -6880,6 +6900,10 @@ type ModelApplyStartupOverlayRequest struct { DeferredResume *bool `json:"deferredResume,omitempty"` // Model required by device-managed policy, when configured. DeviceManagedModel *string `json:"deviceManagedModel,omitempty"` + // Startup default model from the enterprise policy helper, when configured. Weakest of the + // managed sources: it applies only when neither device nor server policy names a model, and + // an explicit user selection still wins. + PolicyHelperModel *string `json:"policyHelperModel,omitempty"` // Context tier selected by repository settings, when configured. RepoContextTier *string `json:"repoContextTier,omitempty"` // Model selected by repository settings, when configured. @@ -6921,6 +6945,12 @@ type ModelBillingPromo struct { // Human-readable promotion message. Does not include the expiry timestamp; consumers may // format endsAt and append it when present. Message *string `json:"message,omitempty"` + // Whether the service asked hosts to give this promotion a prominent surface, such as a + // dedicated banner, in addition to listing it with the model. `true` requests that surface + // and `false` asks for the model list only. Absent means the service expressed no + // preference — for example a response that predates the field — so hosts should apply their + // own default rather than read it as `false`. + ShowBanner *bool `json:"showBanner,omitempty"` } // Token-level pricing information for this model @@ -13703,6 +13733,8 @@ func (SlashCommandAgentPromptResult) Kind() SlashCommandInvocationResultKind { type SlashCommandCompletedResult struct { // Optional user-facing message describing the completed command Message *string `json:"message,omitempty"` + // Optional target session mode applied without submitting an agent prompt + Mode *SessionMode `json:"mode,omitempty"` // True when the invocation mutated user runtime settings; consumers caching settings should // refresh RuntimeSettingsChanged *bool `json:"runtimeSettingsChanged,omitempty"` @@ -16122,14 +16154,20 @@ const ( CatalogNetworkFailureReasonConnectionRefused CatalogNetworkFailureReason = "connection-refused" // The authority's name could not be resolved. CatalogNetworkFailureReasonDns CatalogNetworkFailureReason = "dns" - // The authority returned a status the runtime treats as a failure. + // The authority returned another status the runtime treats as a failure. CatalogNetworkFailureReasonHTTPStatus CatalogNetworkFailureReason = "http-status" // No network is available, so nothing was attempted. CatalogNetworkFailureReasonOffline CatalogNetworkFailureReason = "offline" + // The configured proxy returned 407 and requires authentication. + CatalogNetworkFailureReasonProxyAuthenticationRequired CatalogNetworkFailureReason = "proxy-authentication-required" + // The authority rate-limited requests and supplied or implied a bounded cooldown. + CatalogNetworkFailureReasonRateLimited CatalogNetworkFailureReason = "rate-limited" // A redirect was refused by the runtime's redirect policy. CatalogNetworkFailureReasonRedirectRejected CatalogNetworkFailureReason = "redirect-rejected" // The response exceeded the permitted size. CatalogNetworkFailureReasonResponseTooLarge CatalogNetworkFailureReason = "response-too-large" + // The authority returned a transient 5xx response. + CatalogNetworkFailureReasonServiceUnavailable CatalogNetworkFailureReason = "service-unavailable" // The request exceeded its time budget. CatalogNetworkFailureReasonTimeout CatalogNetworkFailureReason = "timeout" // The TLS handshake or certificate validation failed. @@ -16592,6 +16630,7 @@ const ( FactoryRunFailureTypeFactoryAccountingIncomplete FactoryRunFailureType = "factory_accounting_incomplete" FactoryRunFailureTypeFactoryDurableFailure FactoryRunFailureType = "factory_durable_failure" FactoryRunFailureTypeFactoryLimitReached FactoryRunFailureType = "factory_limit_reached" + FactoryRunFailureTypeFactoryProviderDisconnected FactoryRunFailureType = "factory_provider_disconnected" FactoryRunFailureTypeFactoryResumeDeclined FactoryRunFailureType = "factory_resume_declined" ) @@ -27461,6 +27500,9 @@ func (a *InternalModelAPI) ApplyStartupOverlay(ctx context.Context, params *Mode if params.DeviceManagedModel != nil { req["deviceManagedModel"] = *params.DeviceManagedModel } + if params.PolicyHelperModel != nil { + req["policyHelperModel"] = *params.PolicyHelperModel + } if params.RepoContextTier != nil { req["repoContextTier"] = *params.RepoContextTier } diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go index 78788660f5..197e5b5437 100644 --- a/go/rpc/zrpc_encoding.go +++ b/go/rpc/zrpc_encoding.go @@ -1606,6 +1606,12 @@ func unmarshalFactoryRunFailure(data []byte) (FactoryRunFailure, error) { return nil, err } return &d, nil + case FactoryRunFailureTypeFactoryProviderDisconnected: + var d FactoryRunFailureFactoryProviderDisconnected + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil case FactoryRunFailureTypeFactoryResumeDeclined: var d FactoryRunFailureFactoryResumeDeclined if err := json.Unmarshal(data, &d); err != nil { @@ -1661,6 +1667,17 @@ func (r FactoryRunFailureFactoryLimitReached) MarshalJSON() ([]byte, error) { }) } +func (r FactoryRunFailureFactoryProviderDisconnected) MarshalJSON() ([]byte, error) { + type alias FactoryRunFailureFactoryProviderDisconnected + return json.Marshal(struct { + Type FactoryRunFailureType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + func (r FactoryRunFailureFactoryResumeDeclined) MarshalJSON() ([]byte, error) { type alias FactoryRunFailureFactoryResumeDeclined return json.Marshal(struct { @@ -1698,6 +1715,7 @@ func (r *FactoryRunTerminal) UnmarshalJSON(data []byte) error { func (r *FactoryRunResult) UnmarshalJSON(data []byte) error { type rawFactoryRunResult struct { + Attempt *int64 `json:"attempt,omitempty"` Error *string `json:"error,omitempty"` Failure json.RawMessage `json:"failure,omitempty"` Reason *string `json:"reason,omitempty"` @@ -1710,6 +1728,7 @@ func (r *FactoryRunResult) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &raw); err != nil { return err } + r.Attempt = raw.Attempt r.Error = raw.Error if raw.Failure != nil { value, err := unmarshalFactoryRunFailure(raw.Failure) diff --git a/go/rpc/zsession_encoding.go b/go/rpc/zsession_encoding.go index 4c03a42c00..05e372402b 100644 --- a/go/rpc/zsession_encoding.go +++ b/go/rpc/zsession_encoding.go @@ -575,6 +575,12 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeSessionModeNoticeDelivered: + var d SessionModeNoticeDeliveredData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeSessionPermissionsChanged: var d SessionPermissionsChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index acea96f5b9..0f93b52f18 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -183,6 +183,7 @@ const ( SessionEventTypeSessionMCPServerStatusChanged SessionEventType = "session.mcp_server_status_changed" SessionEventTypeSessionModeChanged SessionEventType = "session.mode_changed" SessionEventTypeSessionModelChange SessionEventType = "session.model_change" + SessionEventTypeSessionModeNoticeDelivered SessionEventType = "session.mode_notice_delivered" // Experimental: SessionEventTypeSessionPermissionsChanged identifies an experimental event // that may change or be removed. SessionEventTypeSessionPermissionsChanged SessionEventType = "session.permissions_changed" @@ -768,7 +769,7 @@ func (*PendingMessagesModifiedData) Type() SessionEventType { return SessionEventTypePendingMessagesModified } -// Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. +// Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. // Experimental: SessionManagedSettingsResolvedData is part of an experimental API and may change or be removed. type SessionManagedSettingsResolvedData struct { // Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. @@ -783,13 +784,15 @@ type SessionManagedSettingsResolvedData struct { ManagedKeys []string `json:"managedKeys"` // Whether at least two managed sources supplied permission allowlists, so enforcement intersects them and the flattened settings payload omits `permissions.allow`. PermissionsAllowIntersected *bool `json:"permissionsAllowIntersected,omitempty"` + // Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. + PolicyHelperManaged *bool `json:"policyHelperManaged,omitempty"` // Whether the effective sandbox policy forces the sandbox on *only* because managed policy could not be determined, rather than because the policy requires it. Lets clients tell a user whose `--no-sandbox` was overridden that the sandbox stayed on as a fail-closed fallback, instead of attributing it to an administrator who set no such policy. SandboxEnabledByUndeterminedPolicy *bool `json:"sandboxEnabledByUndeterminedPolicy,omitempty"` // Whether the server (account/org) managed-settings layer was present ServerManaged bool `json:"serverManaged"` // The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. Settings any `json:"settings,omitempty"` - // Channel summary: `server`, `device`, or `client` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. + // Channel summary: `server`, `device`, `client`, or `policyHelper` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. Source ManagedSettingsResolvedSource `json:"source"` } @@ -1960,6 +1963,19 @@ type CommandQueuedData struct { func (*CommandQueuedData) sessionEventData() {} func (*CommandQueuedData) Type() SessionEventType { return SessionEventTypeCommandQueued } +// Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume. +type SessionModeNoticeDeliveredData struct { + // Model-visible transition notice persisted for a mid-turn delivery + Content *string `json:"content,omitempty"` + // Mode established by the delivered transition notice + Mode SessionMode `json:"mode"` +} + +func (*SessionModeNoticeDeliveredData) sessionEventData() {} +func (*SessionModeNoticeDeliveredData) Type() SessionEventType { + return SessionEventTypeSessionModeNoticeDelivered +} + // Registered command dispatch request routed to the owning client type CommandExecuteData struct { // Raw argument string after the command name @@ -2787,7 +2803,7 @@ func (*SessionWorkspaceFileChangedData) Type() SessionEventType { // Neutral provider-tagged reasoning content blocks preserved verbatim for round-tripping // Experimental: AssistantMessageReasoningBlocks is part of an experimental API and may change or be removed. type AssistantMessageReasoningBlocks struct { - // Provider-native reasoning content blocks (e.g. Anthropic `thinking` / `redacted_thinking`) preserved verbatim, in order. A single response can carry several, each signed over the content preceding it, so dropping or reordering any of them invalidates the rest. + // Provider-native reasoning items or content blocks preserved verbatim, in order. A single response can carry several, and provider signatures or identifiers may depend on their exact content and ordering. Blocks []any `json:"blocks,omitzero"` // Model provider that produced these reasoning blocks. Provider string `json:"provider"` @@ -5284,10 +5300,12 @@ const ( ManagedSettingsResolvedSourceClient ManagedSettingsResolvedSource = "client" // Only the device MDM/plist/registry/file channel contributed. ManagedSettingsResolvedSourceDevice ManagedSettingsResolvedSource = "device" - // More than one channel contributed. Ordinary keys resolve device over server per key, while permissions compose restrictively across all present layers. + // More than one channel contributed. Ordinary keys resolve device over server over policy helper per key, while permissions compose restrictively across all present layers. ManagedSettingsResolvedSourceMixed ManagedSettingsResolvedSource = "mixed" // No managed policy is in force (no channel contributed). ManagedSettingsResolvedSourceNone ManagedSettingsResolvedSource = "none" + // A policy helper registered by device or server policy contributed. Device registration takes priority when present. + ManagedSettingsResolvedSourcePolicyHelper ManagedSettingsResolvedSource = "policyHelper" // Only the server/account channel contributed. ManagedSettingsResolvedSourceServer ManagedSettingsResolvedSource = "server" ) diff --git a/go/zsession_events.go b/go/zsession_events.go index bdd9c918f8..360f1a7588 100644 --- a/go/zsession_events.go +++ b/go/zsession_events.go @@ -294,6 +294,7 @@ type ( SessionMode = rpc.SessionMode SessionModeChangedData = rpc.SessionModeChangedData SessionModelChangeData = rpc.SessionModelChangeData + SessionModeNoticeDeliveredData = rpc.SessionModeNoticeDeliveredData SessionPermissionsChangedData = rpc.SessionPermissionsChangedData SessionPlanChangedData = rpc.SessionPlanChangedData SessionRemoteSteerableChangedData = rpc.SessionRemoteSteerableChangedData @@ -551,6 +552,7 @@ const ( ManagedSettingsResolvedSourceDevice = rpc.ManagedSettingsResolvedSourceDevice ManagedSettingsResolvedSourceMixed = rpc.ManagedSettingsResolvedSourceMixed ManagedSettingsResolvedSourceNone = rpc.ManagedSettingsResolvedSourceNone + ManagedSettingsResolvedSourcePolicyHelper = rpc.ManagedSettingsResolvedSourcePolicyHelper ManagedSettingsResolvedSourceServer = rpc.ManagedSettingsResolvedSourceServer MCPHeadersRefreshCompletedOutcomeHeaders = rpc.MCPHeadersRefreshCompletedOutcomeHeaders MCPHeadersRefreshCompletedOutcomeNone = rpc.MCPHeadersRefreshCompletedOutcomeNone @@ -757,6 +759,7 @@ const ( SessionEventTypeSessionMCPServerStatusChanged = rpc.SessionEventTypeSessionMCPServerStatusChanged SessionEventTypeSessionModeChanged = rpc.SessionEventTypeSessionModeChanged SessionEventTypeSessionModelChange = rpc.SessionEventTypeSessionModelChange + SessionEventTypeSessionModeNoticeDelivered = rpc.SessionEventTypeSessionModeNoticeDelivered SessionEventTypeSessionPermissionsChanged = rpc.SessionEventTypeSessionPermissionsChanged SessionEventTypeSessionPlanChanged = rpc.SessionEventTypeSessionPlanChanged SessionEventTypeSessionRemoteSteerableChanged = rpc.SessionEventTypeSessionRemoteSteerableChanged diff --git a/java/pom.xml b/java/pom.xml index 91a97aadff..30d9d582d6 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -63,7 +63,7 @@ DO NOT EDIT MANUALLY. Updated by the update-copilot-dependency workflow. --> - ^1.0.83-0 + ^1.0.83-1 true diff --git a/java/scripts/codegen/package-lock.json b/java/scripts/codegen/package-lock.json index fd177ca1e8..2c4712269a 100644 --- a/java/scripts/codegen/package-lock.json +++ b/java/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.83-0", + "@github/copilot": "^1.0.83-1", "json-schema": "^0.4.0", "tsx": "^4.23.12" } @@ -428,9 +428,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", - "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-1.tgz", + "integrity": "sha512-7rhgbgSx7IgfFYXrTx2/2+qowv6MZjC6BN0mhba37RuGQxsca9Zk2ucAeIX3GbQTp91AhvnN0aHCxabX36xADQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -439,20 +439,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.83-0", - "@github/copilot-darwin-x64": "1.0.83-0", - "@github/copilot-linux-arm64": "1.0.83-0", - "@github/copilot-linux-x64": "1.0.83-0", - "@github/copilot-linuxmusl-arm64": "1.0.83-0", - "@github/copilot-linuxmusl-x64": "1.0.83-0", - "@github/copilot-win32-arm64": "1.0.83-0", - "@github/copilot-win32-x64": "1.0.83-0" + "@github/copilot-darwin-arm64": "1.0.83-1", + "@github/copilot-darwin-x64": "1.0.83-1", + "@github/copilot-linux-arm64": "1.0.83-1", + "@github/copilot-linux-x64": "1.0.83-1", + "@github/copilot-linuxmusl-arm64": "1.0.83-1", + "@github/copilot-linuxmusl-x64": "1.0.83-1", + "@github/copilot-win32-arm64": "1.0.83-1", + "@github/copilot-win32-x64": "1.0.83-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", - "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-1.tgz", + "integrity": "sha512-gxYJEd4yoIBIQHRmJw6ZegSkR6VuNm7AJVdAdARcU0SS68VZjtZFT29luThAp75Pxp37S5w+3A/jQNtxGyHizw==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", - "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-1.tgz", + "integrity": "sha512-PL63+Ib1RH+Bpu2UYJ9E8tmD4NZD/YlhD25F1Q9Rb86AdgLEoRYlxjNxXXF9P9dMmJ/u5zm0rUtWIRxv/FRbPg==", "cpu": [ "x64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", - "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-1.tgz", + "integrity": "sha512-7c4RVEuXQu9zepAWoXwkizpESztUbWUR1hLPl+o+19L+D8fXm+5XQRH5E22Ia7P7/xHW3Ml2o2x5cXFq0k7/7w==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", - "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-1.tgz", + "integrity": "sha512-Be0zUVysLYV66pnOPhMxpShQ8Ox5V07qcsSOoNzG7iwpzkfZuf70U9CHk/fnI//mWwmZR+caPUfVM9BpwrdNnA==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", - "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-1.tgz", + "integrity": "sha512-PZNP8vZZJU8yIc3dNjGUBUCFPNjqZs8FPZLDxvL6XdHM7Ep4cLBa+QdbRsw+nZONIaG0GtQf26eqk5ymYYpUPA==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", - "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-1.tgz", + "integrity": "sha512-uKMhGtg5K07nA5/pxDSq8S5F/I+sMgVhUXg90Y4BI9trOidgxpXpCVyh4TeRNj2ENNugPvATdfGc6eFPsjmlgQ==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", - "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-1.tgz", + "integrity": "sha512-vMxHTmv3SotBuNI4LKcIFckd+n0OvhTik27qjsqOnVqFpvmHDghSsa8YPxySyxUQIE35z5pJaivVBK3dfEAY7w==", "cpu": [ "arm64" ], @@ -562,9 +562,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.83-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", - "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", + "version": "1.0.83-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-1.tgz", + "integrity": "sha512-2l0VVTazW3/+p94ZQ5sAPCj1iH7KWxJAYh4yXWfvpsB/1JEEmyVgpLei1MeaMFVdtp/hi9bGprZ9+zWNlHh5hQ==", "cpu": [ "x64" ], diff --git a/java/scripts/codegen/package.json b/java/scripts/codegen/package.json index 8089a79133..2e2e1f7e19 100644 --- a/java/scripts/codegen/package.json +++ b/java/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.83-0", + "@github/copilot": "^1.0.83-1", "json-schema": "^0.4.0", "tsx": "^4.23.12" } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageReasoningBlocks.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageReasoningBlocks.java index d2ad87f7c4..ee9c5fd485 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageReasoningBlocks.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageReasoningBlocks.java @@ -24,7 +24,7 @@ public record AssistantMessageReasoningBlocks( /** Model provider that produced these reasoning blocks. */ @JsonProperty("provider") String provider, - /** Provider-native reasoning content blocks (e.g. Anthropic `thinking` / `redacted_thinking`) preserved verbatim, in order. A single response can carry several, each signed over the content preceding it, so dropping or reordering any of them invalidates the rest. */ + /** Provider-native reasoning items or content blocks preserved verbatim, in order. A single response can carry several, and provider signatures or identifiers may depend on their exact content and ordering. */ @JsonProperty("blocks") List blocks ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/ManagedSettingsResolvedSource.java b/java/sdk/src/generated/java/com/github/copilot/generated/ManagedSettingsResolvedSource.java index 32386f898a..90ce9de668 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/ManagedSettingsResolvedSource.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/ManagedSettingsResolvedSource.java @@ -22,6 +22,8 @@ public enum ManagedSettingsResolvedSource { DEVICE("device"), /** The {@code client} variant. */ CLIENT("client"), + /** The {@code policyHelper} variant. */ + POLICYHELPER("policyHelper"), /** The {@code mixed} variant. */ MIXED("mixed"), /** The {@code none} variant. */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionEvent.java index e22561b914..bf5dc65769 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionEvent.java @@ -39,6 +39,7 @@ @JsonSubTypes.Type(value = SessionWarningEvent.class, name = "session.warning"), @JsonSubTypes.Type(value = SessionModelChangeEvent.class, name = "session.model_change"), @JsonSubTypes.Type(value = SessionModeChangedEvent.class, name = "session.mode_changed"), + @JsonSubTypes.Type(value = SessionModeNoticeDeliveredEvent.class, name = "session.mode_notice_delivered"), @JsonSubTypes.Type(value = SessionSessionLimitsChangedEvent.class, name = "session.session_limits_changed"), @JsonSubTypes.Type(value = SessionPermissionsChangedEvent.class, name = "session.permissions_changed"), @JsonSubTypes.Type(value = SessionPlanChangedEvent.class, name = "session.plan_changed"), @@ -172,6 +173,7 @@ public abstract sealed class SessionEvent permits SessionWarningEvent, SessionModelChangeEvent, SessionModeChangedEvent, + SessionModeNoticeDeliveredEvent, SessionSessionLimitsChangedEvent, SessionPermissionsChangedEvent, SessionPlanChangedEvent, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionManagedSettingsResolvedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionManagedSettingsResolvedEvent.java index ac3763248a..e5428d26db 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionManagedSettingsResolvedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionManagedSettingsResolvedEvent.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. + * Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. * @since 1.0.0 */ @JsonIgnoreProperties(ignoreUnknown = true) @@ -35,7 +35,7 @@ public final class SessionManagedSettingsResolvedEvent extends SessionEvent { @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public record SessionManagedSettingsResolvedEventData( - /** Channel summary: `server`, `device`, or `client` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. */ + /** Channel summary: `server`, `device`, `client`, or `policyHelper` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. */ @JsonProperty("source") ManagedSettingsResolvedSource source, /** Whether the server (account/org) managed-settings layer was present */ @JsonProperty("serverManaged") Boolean serverManaged, @@ -43,6 +43,8 @@ public record SessionManagedSettingsResolvedEventData( @JsonProperty("deviceManaged") Boolean deviceManaged, /** Whether a session-local permissions layer injected by the SDK host was present */ @JsonProperty("clientManaged") Boolean clientManaged, + /** Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. */ + @JsonProperty("policyHelperManaged") Boolean policyHelperManaged, /** Whether managed policy could not be determined (e.g. a failed server fetch) and the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. */ @JsonProperty("failClosed") Boolean failClosed, /** Whether the effective sandbox policy forces the sandbox on *only* because managed policy could not be determined, rather than because the policy requires it. Lets clients tell a user whose `--no-sandbox` was overridden that the sandbox stayed on as a fail-closed fallback, instead of attributing it to an administrator who set no such policy. */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionModeNoticeDeliveredEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionModeNoticeDeliveredEvent.java new file mode 100644 index 0000000000..9e24109f9a --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionModeNoticeDeliveredEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Session event "session.mode_notice_delivered". Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume. + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class SessionModeNoticeDeliveredEvent extends SessionEvent { + + @Override + public String getType() { return "session.mode_notice_delivered"; } + + @JsonProperty("data") + private SessionModeNoticeDeliveredEventData data; + + public SessionModeNoticeDeliveredEventData getData() { return data; } + public void setData(SessionModeNoticeDeliveredEventData data) { this.data = data; } + + /** Data payload for {@link SessionModeNoticeDeliveredEvent}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionModeNoticeDeliveredEventData( + /** Mode established by the delivered transition notice */ + @JsonProperty("mode") SessionMode mode, + /** Model-visible transition notice persisted for a mid-turn delivery */ + @JsonProperty("content") String content + ) { + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureError.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureError.java index d4afb32c3d..01d92e908a 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureError.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureError.java @@ -36,6 +36,10 @@ public final class CatalogNetworkFailureError extends CatalogSearchResult { @JsonProperty("statusCode") private Long statusCode; + /** Bounded cooldown in seconds before another catalog request should be attempted, when the authority supplied a numeric Retry-After value or the runtime applied its documented fallback. */ + @JsonProperty("retryAfterSeconds") + private Long retryAfterSeconds; + /** Human-readable explanation, safe to surface. Never contains a query, URL, handle, or secret. */ @JsonProperty("message") private String message; @@ -46,6 +50,9 @@ public final class CatalogNetworkFailureError extends CatalogSearchResult { public Long getStatusCode() { return statusCode; } public void setStatusCode(Long statusCode) { this.statusCode = statusCode; } + public Long getRetryAfterSeconds() { return retryAfterSeconds; } + public void setRetryAfterSeconds(Long retryAfterSeconds) { this.retryAfterSeconds = retryAfterSeconds; } + public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureReason.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureReason.java index c28d1cc4a7..ac821b062d 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureReason.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogNetworkFailureReason.java @@ -26,6 +26,12 @@ public enum CatalogNetworkFailureReason { TLS("tls"), /** The {@code connection-refused} variant. */ CONNECTION_REFUSED("connection-refused"), + /** The {@code proxy-authentication-required} variant. */ + PROXY_AUTHENTICATION_REQUIRED("proxy-authentication-required"), + /** The {@code rate-limited} variant. */ + RATE_LIMITED("rate-limited"), + /** The {@code service-unavailable} variant. */ + SERVICE_UNAVAILABLE("service-unavailable"), /** The {@code http-status} variant. */ HTTP_STATUS("http-status"), /** The {@code response-too-large} variant. */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchParams.java index ee5e703621..68b90a19c9 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchParams.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/CatalogSearchParams.java @@ -27,7 +27,7 @@ public record CatalogSearchParams( /** Protocol version and capabilities the caller requires. */ @JsonProperty("contract") CatalogClientContract contract, - /** Free-text search query. Never written to logs or telemetry. */ + /** Free-text search query. Persisted as tool input for session continuity, but omitted from telemetry. */ @JsonProperty("query") String query, /** Maximum number of candidates to return. Defaults to 10 when omitted. */ @JsonProperty("limit") Long limit, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/FactoryRunResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/FactoryRunResult.java index bb28f40887..71ee3c49e6 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/FactoryRunResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/FactoryRunResult.java @@ -23,13 +23,15 @@ public record FactoryRunResult( /** Factory run identifier. */ @JsonProperty("runId") String runId, + /** One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. */ + @JsonProperty("attempt") Long attempt, /** Current or terminal factory run status. */ @JsonProperty("status") FactoryRunStatus status, /** Completed factory result. */ @JsonProperty("result") Object result, /** Error message for an errored run. */ @JsonProperty("error") String error, - /** Machine-readable failure details for an errored run. */ + /** Machine-readable failure details for a halted or errored run. */ @JsonProperty("failure") Object failure, /** Reason for a halted or cancelled run. */ @JsonProperty("reason") String reason, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ModelBillingPromo.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ModelBillingPromo.java index 087ca1c15a..d44087dcff 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ModelBillingPromo.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ModelBillingPromo.java @@ -28,6 +28,8 @@ public record ModelBillingPromo( /** UTC ISO 8601 timestamp marking when the promotion ends. Optional: an open-ended promotion omits this field. When present, the API only surfaces a promo whose expiry parses and is in the future, so consumers should treat a past value as expired. */ @JsonProperty("endsAt") String endsAt, /** Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it when present. */ - @JsonProperty("message") String message + @JsonProperty("message") String message, + /** Whether the service asked hosts to give this promotion a prominent surface, such as a dedicated banner, in addition to listing it with the model. `true` requests that surface and `false` asks for the model list only. Absent means the service expressed no preference — for example a response that predates the field — so hosts should apply their own default rather than read it as `false`. */ + @JsonProperty("showBanner") Boolean showBanner ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryCancelResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryCancelResult.java index 0cb66280c0..c9f9de2dcc 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryCancelResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryCancelResult.java @@ -26,13 +26,15 @@ public record SessionFactoryCancelResult( /** Factory run identifier. */ @JsonProperty("runId") String runId, + /** One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. */ + @JsonProperty("attempt") Long attempt, /** Current or terminal factory run status. */ @JsonProperty("status") FactoryRunStatus status, /** Completed factory result. */ @JsonProperty("result") Object result, /** Error message for an errored run. */ @JsonProperty("error") String error, - /** Machine-readable failure details for an errored run. */ + /** Machine-readable failure details for a halted or errored run. */ @JsonProperty("failure") Object failure, /** Reason for a halted or cancelled run. */ @JsonProperty("reason") String reason, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryGetRunResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryGetRunResult.java index 6742faf03e..2d6a5f52a9 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryGetRunResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryGetRunResult.java @@ -26,13 +26,15 @@ public record SessionFactoryGetRunResult( /** Factory run identifier. */ @JsonProperty("runId") String runId, + /** One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. */ + @JsonProperty("attempt") Long attempt, /** Current or terminal factory run status. */ @JsonProperty("status") FactoryRunStatus status, /** Completed factory result. */ @JsonProperty("result") Object result, /** Error message for an errored run. */ @JsonProperty("error") String error, - /** Machine-readable failure details for an errored run. */ + /** Machine-readable failure details for a halted or errored run. */ @JsonProperty("failure") Object failure, /** Reason for a halted or cancelled run. */ @JsonProperty("reason") String reason, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunFromToolResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunFromToolResult.java index 71b12f94f1..1a9dee5926 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunFromToolResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunFromToolResult.java @@ -26,13 +26,15 @@ public record SessionFactoryRunFromToolResult( /** Factory run identifier. */ @JsonProperty("runId") String runId, + /** One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. */ + @JsonProperty("attempt") Long attempt, /** Current or terminal factory run status. */ @JsonProperty("status") FactoryRunStatus status, /** Completed factory result. */ @JsonProperty("result") Object result, /** Error message for an errored run. */ @JsonProperty("error") String error, - /** Machine-readable failure details for an errored run. */ + /** Machine-readable failure details for a halted or errored run. */ @JsonProperty("failure") Object failure, /** Reason for a halted or cancelled run. */ @JsonProperty("reason") String reason, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunResult.java index 46083f2284..d8ce481895 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionFactoryRunResult.java @@ -26,13 +26,15 @@ public record SessionFactoryRunResult( /** Factory run identifier. */ @JsonProperty("runId") String runId, + /** One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. */ + @JsonProperty("attempt") Long attempt, /** Current or terminal factory run status. */ @JsonProperty("status") FactoryRunStatus status, /** Completed factory result. */ @JsonProperty("result") Object result, /** Error message for an errored run. */ @JsonProperty("error") String error, - /** Machine-readable failure details for an errored run. */ + /** Machine-readable failure details for a halted or errored run. */ @JsonProperty("failure") Object failure, /** Reason for a halted or cancelled run. */ @JsonProperty("reason") String reason, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionModelApplyStartupOverlayParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionModelApplyStartupOverlayParams.java index a1d25ae7ec..dfb593fd21 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionModelApplyStartupOverlayParams.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionModelApplyStartupOverlayParams.java @@ -30,6 +30,8 @@ public record SessionModelApplyStartupOverlayParams( @JsonProperty("deviceManagedModel") String deviceManagedModel, /** Model required by server-managed policy, when configured. */ @JsonProperty("serverManagedModel") String serverManagedModel, + /** Startup default model from the enterprise policy helper, when configured. Weakest of the managed sources: it applies only when neither device nor server policy names a model, and an explicit user selection still wins. */ + @JsonProperty("policyHelperModel") String policyHelperModel, /** Model selected by repository settings, when configured. */ @JsonProperty("repoModel") String repoModel, /** Reasoning effort selected by repository settings, when configured. */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SlashCommandCompletedResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SlashCommandCompletedResult.java index b2a1970a36..c457181435 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SlashCommandCompletedResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SlashCommandCompletedResult.java @@ -32,6 +32,10 @@ public final class SlashCommandCompletedResult extends SlashCommandInvocationRes @JsonProperty("message") private String message; + /** Optional target session mode applied without submitting an agent prompt */ + @JsonProperty("mode") + private SessionMode mode; + /** True when the invocation mutated user runtime settings; consumers caching settings should refresh */ @JsonProperty("runtimeSettingsChanged") private Boolean runtimeSettingsChanged; @@ -39,6 +43,9 @@ public final class SlashCommandCompletedResult extends SlashCommandInvocationRes public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } + public SessionMode getMode() { return mode; } + public void setMode(SessionMode mode) { this.mode = mode; } + public Boolean getRuntimeSettingsChanged() { return runtimeSettingsChanged; } public void setRuntimeSettingsChanged(Boolean runtimeSettingsChanged) { this.runtimeSettingsChanged = runtimeSettingsChanged; } } diff --git a/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java b/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java index 602089d012..638f535582 100644 --- a/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java +++ b/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java @@ -816,7 +816,7 @@ void modelsListResult_nested() { var limits = new ModelCapabilitiesLimits(100000L, 8192L, 128000L, null); var capabilities = new ModelCapabilities(supports, limits); var policy = new ModelPolicy(ModelPolicyState.ENABLED, null); - var promo = new ModelBillingPromo("summer-2026", 25.0, "2026-08-01T00:00:00Z", "Summer discount"); + var promo = new ModelBillingPromo("summer-2026", 25.0, "2026-08-01T00:00:00Z", "Summer discount", true); var billing = new ModelBilling(1.0, null, null, promo); var modelItem = new Model("gpt-5", "GPT-5", capabilities, policy, billing, null, null, null, null, null, null, null, null); @@ -834,6 +834,7 @@ void modelsListResult_nested() { assertEquals(Double.valueOf(25.0), result.models().get(0).billing().promo().discountPercent()); assertEquals("2026-08-01T00:00:00Z", result.models().get(0).billing().promo().endsAt()); assertEquals("Summer discount", result.models().get(0).billing().promo().message()); + assertTrue(result.models().get(0).billing().promo().showBanner()); } @Test diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 6480e85e29..39bb781f7b 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.83-0", + "@github/copilot": "^1.0.83-1", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -658,8 +658,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.83-0", - "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", + "version": "1.0.83-1", + "integrity": "sha512-7rhgbgSx7IgfFYXrTx2/2+qowv6MZjC6BN0mhba37RuGQxsca9Zk2ucAeIX3GbQTp91AhvnN0aHCxabX36xADQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -668,19 +668,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.83-0", - "@github/copilot-darwin-x64": "1.0.83-0", - "@github/copilot-linux-arm64": "1.0.83-0", - "@github/copilot-linux-x64": "1.0.83-0", - "@github/copilot-linuxmusl-arm64": "1.0.83-0", - "@github/copilot-linuxmusl-x64": "1.0.83-0", - "@github/copilot-win32-arm64": "1.0.83-0", - "@github/copilot-win32-x64": "1.0.83-0" + "@github/copilot-darwin-arm64": "1.0.83-1", + "@github/copilot-darwin-x64": "1.0.83-1", + "@github/copilot-linux-arm64": "1.0.83-1", + "@github/copilot-linux-x64": "1.0.83-1", + "@github/copilot-linuxmusl-arm64": "1.0.83-1", + "@github/copilot-linuxmusl-x64": "1.0.83-1", + "@github/copilot-win32-arm64": "1.0.83-1", + "@github/copilot-win32-x64": "1.0.83-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", + "version": "1.0.83-1", + "integrity": "sha512-gxYJEd4yoIBIQHRmJw6ZegSkR6VuNm7AJVdAdARcU0SS68VZjtZFT29luThAp75Pxp37S5w+3A/jQNtxGyHizw==", "cpu": [ "arm64" ], @@ -694,8 +694,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.83-0", - "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", + "version": "1.0.83-1", + "integrity": "sha512-PL63+Ib1RH+Bpu2UYJ9E8tmD4NZD/YlhD25F1Q9Rb86AdgLEoRYlxjNxXXF9P9dMmJ/u5zm0rUtWIRxv/FRbPg==", "cpu": [ "x64" ], @@ -709,8 +709,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", + "version": "1.0.83-1", + "integrity": "sha512-7c4RVEuXQu9zepAWoXwkizpESztUbWUR1hLPl+o+19L+D8fXm+5XQRH5E22Ia7P7/xHW3Ml2o2x5cXFq0k7/7w==", "cpu": [ "arm64" ], @@ -724,8 +724,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.83-0", - "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", + "version": "1.0.83-1", + "integrity": "sha512-Be0zUVysLYV66pnOPhMxpShQ8Ox5V07qcsSOoNzG7iwpzkfZuf70U9CHk/fnI//mWwmZR+caPUfVM9BpwrdNnA==", "cpu": [ "x64" ], @@ -739,8 +739,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", + "version": "1.0.83-1", + "integrity": "sha512-PZNP8vZZJU8yIc3dNjGUBUCFPNjqZs8FPZLDxvL6XdHM7Ep4cLBa+QdbRsw+nZONIaG0GtQf26eqk5ymYYpUPA==", "cpu": [ "arm64" ], @@ -754,8 +754,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.83-0", - "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", + "version": "1.0.83-1", + "integrity": "sha512-uKMhGtg5K07nA5/pxDSq8S5F/I+sMgVhUXg90Y4BI9trOidgxpXpCVyh4TeRNj2ENNugPvATdfGc6eFPsjmlgQ==", "cpu": [ "x64" ], @@ -769,8 +769,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", + "version": "1.0.83-1", + "integrity": "sha512-vMxHTmv3SotBuNI4LKcIFckd+n0OvhTik27qjsqOnVqFpvmHDghSsa8YPxySyxUQIE35z5pJaivVBK3dfEAY7w==", "cpu": [ "arm64" ], @@ -784,8 +784,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.83-0", - "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", + "version": "1.0.83-1", + "integrity": "sha512-2l0VVTazW3/+p94ZQ5sAPCj1iH7KWxJAYh4yXWfvpsB/1JEEmyVgpLei1MeaMFVdtp/hi9bGprZ9+zWNlHh5hQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index e1744c4fdc..da1892a794 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.83-0", + "@github/copilot": "^1.0.83-1", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 0ef1a077b2..bbb9cea89d 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.83-0", + "@github/copilot": "^1.0.83-1", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 32a002dd01..812c6b2607 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -552,7 +552,13 @@ export type CatalogNetworkFailureReason = | "tls" /** The connection was refused or reset. */ | "connection-refused" - /** The authority returned a status the runtime treats as a failure. */ + /** The configured proxy returned 407 and requires authentication. */ + | "proxy-authentication-required" + /** The authority rate-limited requests and supplied or implied a bounded cooldown. */ + | "rate-limited" + /** The authority returned a transient 5xx response. */ + | "service-unavailable" + /** The authority returned another status the runtime treats as a failure. */ | "http-status" /** The response exceeded the permitted size. */ | "response-too-large" @@ -1127,6 +1133,16 @@ export type FactoryRunFailure = * Factory failure variant discriminator. */ type: "factory_accounting_incomplete"; + } + | { + /** + * Factory run identifier. + */ + runId: string; + /** + * Factory failure variant discriminator. + */ + type: "factory_provider_disconnected"; }; /** * Cumulative resource ceiling that stopped a factory run. @@ -5834,6 +5850,10 @@ export interface CatalogNetworkFailureError { * HTTP status code, when the failure was a rejected response. */ statusCode?: number; + /** + * Bounded cooldown in seconds before another catalog request should be attempted, when the authority supplied a numeric Retry-After value or the runtime applied its documented fallback. + */ + retryAfterSeconds?: number; /** * Human-readable explanation, safe to surface. Never contains a query, URL, handle, or secret. */ @@ -5885,7 +5905,7 @@ export interface CatalogPolicyRejectedError { export interface CatalogSearchRequest { contract: CatalogClientContract; /** - * Free-text search query. Never written to logs or telemetry. + * Free-text search query. Persisted as tool input for session continuity, but omitted from telemetry. */ query: string; /** @@ -8156,6 +8176,10 @@ export interface FactoryRunResult { * Factory run identifier. */ runId: string; + /** + * One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. + */ + attempt?: number; status: FactoryRunStatus; /** * Completed factory result. @@ -12356,6 +12380,10 @@ export interface ModelBillingPromo { * Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it when present. */ message?: string; + /** + * Whether the service asked hosts to give this promotion a prominent surface, such as a dedicated banner, in addition to listing it with the model. `true` requests that surface and `false` asks for the model list only. Absent means the service expressed no preference — for example a response that predates the field — so hosts should apply their own default rather than read it as `false`. + */ + showBanner?: boolean; } /** * Service-published warning text that hosts should display when presenting a model. @@ -12403,6 +12431,10 @@ export interface ModelApplyStartupOverlayRequest { * Model required by server-managed policy, when configured. */ serverManagedModel?: string; + /** + * Startup default model from the enterprise policy helper, when configured. Weakest of the managed sources: it applies only when neither device nor server policy names a model, and an explicit user selection still wins. + */ + policyHelperModel?: string; /** * Model selected by repository settings, when configured. */ @@ -20287,6 +20319,7 @@ export interface SlashCommandCompletedResult { * Optional user-facing message describing the completed command */ message?: string; + mode?: SessionMode; /** * True when the invocation mutated user runtime settings; consumers caching settings should refresh */ diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index c7a610abef..a7049dd9fc 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -24,6 +24,7 @@ export type SessionEvent = | WarningEvent | ModelChangeEvent | ModeChangedEvent + | ModeNoticeDeliveredEvent | SessionLimitsChangedEvent | PermissionsChangedEvent | PlanChangedEvent @@ -947,7 +948,9 @@ export type ManagedSettingsResolvedSource = | "device" /** Only session-local SDK-host injection contributed. */ | "client" - /** More than one channel contributed. Ordinary keys resolve device over server per key, while permissions compose restrictively across all present layers. */ + /** A policy helper registered by device or server policy contributed. Device registration takes priority when present. */ + | "policyHelper" + /** More than one channel contributed. Ordinary keys resolve device over server over policy helper per key, while permissions compose restrictively across all present layers. */ | "mixed" /** No managed policy is in force (no channel contributed). */ | "none"; @@ -1901,6 +1904,46 @@ export interface ModeChangedData { newMode: SessionMode; previousMode: SessionMode; } +/** + * Session event "session.mode_notice_delivered". Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume. + */ +export interface ModeNoticeDeliveredEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: ModeNoticeDeliveredData; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "session.mode_notice_delivered". + */ + type: "session.mode_notice_delivered"; +} +/** + * Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume. + */ +export interface ModeNoticeDeliveredData { + /** + * Model-visible transition notice persisted for a mid-turn delivery + */ + content?: string; + mode: SessionMode; +} /** * Session event "session.session_limits_changed". Session limits update details. Null clears the limits. */ @@ -4815,7 +4858,7 @@ export interface FusionAttribution { /** @experimental */ export interface AssistantMessageReasoningBlocks { /** - * Provider-native reasoning content blocks (e.g. Anthropic `thinking` / `redacted_thinking`) preserved verbatim, in order. A single response can carry several, each signed over the content preceding it, so dropping or reordering any of them invalidates the rest. + * Provider-native reasoning items or content blocks preserved verbatim, in order. A single response can carry several, and provider signatures or identifiers may depend on their exact content and ordering. */ blocks?: JsonValue[]; /** @@ -10068,7 +10111,7 @@ export interface AutoModeResolvedData { stickyOverride?: boolean; } /** - * Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. + * Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. */ /** @experimental */ export interface ManagedSettingsResolvedEvent { @@ -10099,7 +10142,7 @@ export interface ManagedSettingsResolvedEvent { type: "session.managed_settings_resolved"; } /** - * Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. + * Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. */ /** @experimental */ export interface ManagedSettingsResolvedData { @@ -10127,6 +10170,10 @@ export interface ManagedSettingsResolvedData { * Whether at least two managed sources supplied permission allowlists, so enforcement intersects them and the flattened settings payload omits `permissions.allow`. */ permissionsAllowIntersected?: boolean; + /** + * Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. + */ + policyHelperManaged?: boolean; /** * Whether the effective sandbox policy forces the sandbox on *only* because managed policy could not be determined, rather than because the policy requires it. Lets clients tell a user whose `--no-sandbox` was overridden that the sandbox stayed on as a fail-closed fallback, instead of attributing it to an administrator who set no such policy. */ diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 1340d726be..7b629d5c18 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1331,8 +1331,11 @@ class CatalogNetworkFailureReason(Enum): DNS = "dns" HTTP_STATUS = "http-status" OFFLINE = "offline" + PROXY_AUTHENTICATION_REQUIRED = "proxy-authentication-required" + RATE_LIMITED = "rate-limited" REDIRECT_REJECTED = "redirect-rejected" RESPONSE_TOO_LARGE = "response-too-large" + SERVICE_UNAVAILABLE = "service-unavailable" TIMEOUT = "timeout" TLS = "tls" @@ -1427,12 +1430,15 @@ class CatalogSearchResultReason(Enum): NO_CREDENTIAL = "no-credential" OFFLINE = "offline" PLANNING_UNAVAILABLE = "planning-unavailable" + PROXY_AUTHENTICATION_REQUIRED = "proxy-authentication-required" PROXY_REJECTED = "proxy-rejected" + RATE_LIMITED = "rate-limited" REDIRECT_REJECTED = "redirect-rejected" REDIRECT_TO_BLOCKED_ADDRESS = "redirect-to-blocked-address" RESPONSE_TOO_LARGE = "response-too-large" SCHEMA_VIOLATION = "schema-violation" SEARCH_UNAVAILABLE = "search-unavailable" + SERVICE_UNAVAILABLE = "service-unavailable" SIZE_LIMIT_EXCEEDED = "size-limit-exceeded" TIMEOUT = "timeout" TLS = "tls" @@ -3355,6 +3361,7 @@ class FactoryRunFailureType(Enum): FACTORY_ACCOUNTING_INCOMPLETE = "factory_accounting_incomplete" FACTORY_DURABLE_FAILURE = "factory_durable_failure" FACTORY_LIMIT_REACHED = "factory_limit_reached" + FACTORY_PROVIDER_DISCONNECTED = "factory_provider_disconnected" FACTORY_RESUME_DECLINED = "factory_resume_declined" # Experimental: this type is part of an experimental API and may change or be removed. @@ -5736,7 +5743,9 @@ class MCPPlanInstallResultReason(Enum): OFFLINE = "offline" PLANNING_UNAVAILABLE = "planning-unavailable" POLICY_FORBIDS = "policy-forbids" + PROXY_AUTHENTICATION_REQUIRED = "proxy-authentication-required" PROXY_REJECTED = "proxy-rejected" + RATE_LIMITED = "rate-limited" REDIRECT_REJECTED = "redirect-rejected" REDIRECT_TO_BLOCKED_ADDRESS = "redirect-to-blocked-address" REMOTE_ENUMERATION_UNAVAILABLE = "remote-enumeration-unavailable" @@ -5744,6 +5753,7 @@ class MCPPlanInstallResultReason(Enum): RESPONSE_TOO_LARGE = "response-too-large" SCHEMA_VIOLATION = "schema-violation" SEARCH_UNAVAILABLE = "search-unavailable" + SERVICE_UNAVAILABLE = "service-unavailable" SIZE_LIMIT_EXCEEDED = "size-limit-exceeded" STALE = "stale" TIMEOUT = "timeout" @@ -6596,6 +6606,13 @@ class ModelBillingPromo: """Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it when present. """ + show_banner: bool | None = None + """Whether the service asked hosts to give this promotion a prominent surface, such as a + dedicated banner, in addition to listing it with the model. `true` requests that surface + and `false` asks for the model list only. Absent means the service expressed no + preference — for example a response that predates the field — so hosts should apply their + own default rather than read it as `false`. + """ @staticmethod def from_dict(obj: Any) -> 'ModelBillingPromo': @@ -6604,7 +6621,8 @@ def from_dict(obj: Any) -> 'ModelBillingPromo': ends_at = from_union([from_str, from_none], obj.get("endsAt")) id = from_union([from_str, from_none], obj.get("id")) message = from_union([from_str, from_none], obj.get("message")) - return ModelBillingPromo(discount_percent, ends_at, id, message) + show_banner = from_union([from_bool, from_none], obj.get("showBanner")) + return ModelBillingPromo(discount_percent, ends_at, id, message, show_banner) def to_dict(self) -> dict: result: dict = {} @@ -6616,6 +6634,8 @@ def to_dict(self) -> dict: result["id"] = from_union([from_str, from_none], self.id) if self.message is not None: result["message"] = from_union([from_str, from_none], self.message) + if self.show_banner is not None: + result["showBanner"] = from_union([from_bool, from_none], self.show_banner) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -15112,8 +15132,9 @@ class CatalogSearchRequest: """Protocol version and capabilities the caller requires.""" query: str - """Free-text search query. Never written to logs or telemetry.""" - + """Free-text search query. Persisted as tool input for session continuity, but omitted from + telemetry. + """ kinds: list[CatalogCandidateKind] | None = None """Restrict results to these candidate kinds. When omitted, every kind the runtime supports is searched. @@ -15402,6 +15423,11 @@ class CatalogNetworkFailureError: reason: CatalogNetworkFailureReason """Categorised failure, low cardinality so it can be aggregated without carrying a URL.""" + retry_after_seconds: int | None = None + """Bounded cooldown in seconds before another catalog request should be attempted, when the + authority supplied a numeric Retry-After value or the runtime applied its documented + fallback. + """ status_code: int | None = None """HTTP status code, when the failure was a rejected response.""" @@ -15410,14 +15436,17 @@ def from_dict(obj: Any) -> 'CatalogNetworkFailureError': assert isinstance(obj, dict) message = from_str(obj.get("message")) reason = CatalogNetworkFailureReason(obj.get("reason")) + retry_after_seconds = from_union([from_int, from_none], obj.get("retryAfterSeconds")) status_code = from_union([from_int, from_none], obj.get("statusCode")) - return CatalogNetworkFailureError(message, reason, status_code) + return CatalogNetworkFailureError(message, reason, retry_after_seconds, status_code) def to_dict(self) -> dict: result: dict = {} result["kind"] = self.kind result["message"] = from_str(self.message) result["reason"] = to_enum(CatalogNetworkFailureReason, self.reason) + if self.retry_after_seconds is not None: + result["retryAfterSeconds"] = from_union([from_int, from_none], self.retry_after_seconds) if self.status_code is not None: result["statusCode"] = from_union([from_int, from_none], self.status_code) return result @@ -16969,9 +16998,12 @@ class FactoryRunFailure: Machine-readable factory run failure. - Machine-readable failure details for an errored run. + Machine-readable failure details for a halted or errored run. The run stopped because its usage accounting could not be completed. + + The extension that owns the factory disconnected while the run was executing, so the host + halted it. The run's journaled subagent results are preserved so a resume can reuse them. """ run_id: str """Factory run identifier. @@ -23737,6 +23769,9 @@ class SlashCommandCompletedResult: message: str | None = None """Optional user-facing message describing the completed command""" + mode: SessionMode | None = None + """Optional target session mode applied without submitting an agent prompt""" + runtime_settings_changed: bool | None = None """True when the invocation mutated user runtime settings; consumers caching settings should refresh @@ -23746,14 +23781,17 @@ class SlashCommandCompletedResult: def from_dict(obj: Any) -> 'SlashCommandCompletedResult': assert isinstance(obj, dict) message = from_union([from_str, from_none], obj.get("message")) + mode = from_union([SessionMode, from_none], obj.get("mode")) runtime_settings_changed = from_union([from_bool, from_none], obj.get("runtimeSettingsChanged")) - return SlashCommandCompletedResult(message, runtime_settings_changed) + return SlashCommandCompletedResult(message, mode, runtime_settings_changed) def to_dict(self) -> dict: result: dict = {} result["kind"] = self.kind if self.message is not None: result["message"] = from_union([from_str, from_none], self.message) + if self.mode is not None: + result["mode"] = from_union([lambda x: to_enum(SessionMode, x), from_none], self.mode) if self.runtime_settings_changed is not None: result["runtimeSettingsChanged"] = from_union([from_bool, from_none], self.runtime_settings_changed) return result @@ -26040,11 +26078,15 @@ class FactoryRunResult: status: FactoryRunStatus """Current or terminal factory run status.""" + attempt: int | None = None + """One-based execution attempt represented by this envelope. Absent before the first attempt + starts or when returned by an older runtime. + """ error: str | None = None """Error message for an errored run.""" failure: FactoryRunFailure | None = None - """Machine-readable failure details for an errored run.""" + """Machine-readable failure details for a halted or errored run.""" reason: str | None = None """Reason for a halted or cancelled run.""" @@ -26060,17 +26102,20 @@ def from_dict(obj: Any) -> 'FactoryRunResult': assert isinstance(obj, dict) run_id = from_str(obj.get("runId")) status = FactoryRunStatus(obj.get("status")) + attempt = from_union([from_int, from_none], obj.get("attempt")) error = from_union([from_str, from_none], obj.get("error")) failure = from_union([FactoryRunFailure.from_dict, from_none], obj.get("failure")) reason = from_union([from_str, from_none], obj.get("reason")) result = obj.get("result") snapshot = obj.get("snapshot") - return FactoryRunResult(run_id, status, error, failure, reason, result, snapshot) + return FactoryRunResult(run_id, status, attempt, error, failure, reason, result, snapshot) def to_dict(self) -> dict: result: dict = {} result["runId"] = from_str(self.run_id) result["status"] = to_enum(FactoryRunStatus, self.status) + if self.attempt is not None: + result["attempt"] = from_union([from_int, from_none], self.attempt) if self.error is not None: result["error"] = from_union([from_str, from_none], self.error) if self.failure is not None: @@ -33964,6 +34009,11 @@ class ModelApplyStartupOverlayRequest: device_managed_model: str | None = None """Model required by device-managed policy, when configured.""" + policy_helper_model: str | None = None + """Startup default model from the enterprise policy helper, when configured. Weakest of the + managed sources: it applies only when neither device nor server policy names a model, and + an explicit user selection still wins. + """ repo_context_tier: str | None = None """Context tier selected by repository settings, when configured.""" @@ -33982,11 +34032,12 @@ def from_dict(obj: Any) -> 'ModelApplyStartupOverlayRequest': cli_model = from_union([from_str, from_none], obj.get("cliModel")) deferred_resume = from_union([from_bool, from_none], obj.get("deferredResume")) device_managed_model = from_union([from_str, from_none], obj.get("deviceManagedModel")) + policy_helper_model = from_union([from_str, from_none], obj.get("policyHelperModel")) repo_context_tier = from_union([from_str, from_none], obj.get("repoContextTier")) repo_model = from_union([from_str, from_none], obj.get("repoModel")) repo_reasoning_effort = from_union([from_str, from_none], obj.get("repoReasoningEffort")) server_managed_model = from_union([from_str, from_none], obj.get("serverManagedModel")) - return ModelApplyStartupOverlayRequest(cli_model, deferred_resume, device_managed_model, repo_context_tier, repo_model, repo_reasoning_effort, server_managed_model) + return ModelApplyStartupOverlayRequest(cli_model, deferred_resume, device_managed_model, policy_helper_model, repo_context_tier, repo_model, repo_reasoning_effort, server_managed_model) def to_dict(self) -> dict: result: dict = {} @@ -33996,6 +34047,8 @@ def to_dict(self) -> dict: result["deferredResume"] = from_union([from_bool, from_none], self.deferred_resume) if self.device_managed_model is not None: result["deviceManagedModel"] = from_union([from_str, from_none], self.device_managed_model) + if self.policy_helper_model is not None: + result["policyHelperModel"] = from_union([from_str, from_none], self.policy_helper_model) if self.repo_context_tier is not None: result["repoContextTier"] = from_union([from_str, from_none], self.repo_context_tier) if self.repo_model is not None: diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index fd41b6c6c6..20b3df4be4 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -137,6 +137,7 @@ class SessionEventType(Enum): SESSION_WARNING = "session.warning" SESSION_MODEL_CHANGE = "session.model_change" SESSION_MODE_CHANGED = "session.mode_changed" + SESSION_MODE_NOTICE_DELIVERED = "session.mode_notice_delivered" SESSION_SESSION_LIMITS_CHANGED = "session.session_limits_changed" # Experimental: this event is part of an experimental API and may change or be removed. SESSION_PERMISSIONS_CHANGED = "session.permissions_changed" @@ -1992,7 +1993,7 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionManagedSettingsResolvedData: - "Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes." + "Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes." bypass_permissions_disabled: bool device_managed: bool fail_closed: bool @@ -2001,6 +2002,7 @@ class SessionManagedSettingsResolvedData: source: ManagedSettingsResolvedSource client_managed: bool | None = None permissions_allow_intersected: bool | None = None + policy_helper_managed: bool | None = None sandbox_enabled_by_undetermined_policy: bool | None = None settings: Any = None @@ -2015,6 +2017,7 @@ def from_dict(obj: Any) -> "SessionManagedSettingsResolvedData": source = parse_enum(ManagedSettingsResolvedSource, obj.get("source")) client_managed = from_union([from_none, from_bool], obj.get("clientManaged")) permissions_allow_intersected = from_union([from_none, from_bool], obj.get("permissionsAllowIntersected")) + policy_helper_managed = from_union([from_none, from_bool], obj.get("policyHelperManaged")) sandbox_enabled_by_undetermined_policy = from_union([from_none, from_bool], obj.get("sandboxEnabledByUndeterminedPolicy")) settings = obj.get("settings") return SessionManagedSettingsResolvedData( @@ -2026,6 +2029,7 @@ def from_dict(obj: Any) -> "SessionManagedSettingsResolvedData": source=source, client_managed=client_managed, permissions_allow_intersected=permissions_allow_intersected, + policy_helper_managed=policy_helper_managed, sandbox_enabled_by_undetermined_policy=sandbox_enabled_by_undetermined_policy, settings=settings, ) @@ -2042,6 +2046,8 @@ def to_dict(self) -> dict: result["clientManaged"] = from_union([from_none, from_bool], self.client_managed) if self.permissions_allow_intersected is not None: result["permissionsAllowIntersected"] = from_union([from_none, from_bool], self.permissions_allow_intersected) + if self.policy_helper_managed is not None: + result["policyHelperManaged"] = from_union([from_none, from_bool], self.policy_helper_managed) if self.sandbox_enabled_by_undetermined_policy is not None: result["sandboxEnabledByUndeterminedPolicy"] = from_union([from_none, from_bool], self.sandbox_enabled_by_undetermined_policy) if self.settings is not None: @@ -8137,6 +8143,30 @@ def to_dict(self) -> dict: return result +@dataclass +class SessionModeNoticeDeliveredData: + "Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume." + mode: SessionMode + content: str | None = None + + @staticmethod + def from_dict(obj: Any) -> "SessionModeNoticeDeliveredData": + assert isinstance(obj, dict) + mode = parse_enum(SessionMode, obj.get("mode")) + content = from_union([from_none, from_str], obj.get("content")) + return SessionModeNoticeDeliveredData( + mode=mode, + content=content, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["mode"] = to_enum(SessionMode, self.mode) + if self.content is not None: + result["content"] = from_union([from_none, from_str], self.content) + return result + + @dataclass class SessionModelChangeData: "Model change details including previous and new model identifiers" @@ -11920,7 +11950,9 @@ class ManagedSettingsResolvedSource(Enum): DEVICE = "device" # Only session-local SDK-host injection contributed. CLIENT = "client" - # More than one channel contributed. Ordinary keys resolve device over server per key, while permissions compose restrictively across all present layers. + # A policy helper registered by device or server policy contributed. Device registration takes priority when present. + POLICY_HELPER = "policyHelper" + # More than one channel contributed. Ordinary keys resolve device over server over policy helper per key, while permissions compose restrictively across all present layers. MIXED = "mixed" # No managed policy is in force (no channel contributed). NONE = "none" @@ -12334,7 +12366,7 @@ class WorkspaceFileChangedOperation(Enum): UPDATE = "update" -SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionScheduleCreatedData | SessionScheduleCancelledData | SessionScheduleRearmedData | SessionAutopilotObjectiveChangedData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionSessionLimitsChangedData | SessionPermissionsChangedData | SessionPlanChangedData | SessionTodosChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionUsageCheckpointData | SessionContextChangedData | SessionUsageInfoData | SessionContextClearedData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | SessionFusionRouteStartedData | SessionFusionRouteFailedData | SessionFusionResolvedData | SessionFusionCompletedData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantTurnRetryData | AgentInterruptedData | AssistantIntentData | AssistantFusionPhaseStartedData | AssistantFusionPhaseCompletedData | AssistantFusionPhaseFailedData | AssistantServerToolProgressData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantToolCallDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantIdleData | AssistantUsageData | PromptCacheBreakData | ModelCallFailureData | ModelCallFinishedData | ModelCallStartData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | ToolSearchActivatedData | SkillInvokedData | SandboxDecisionData | SubagentStartedData | SubagentConfiguredData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | HookProgressData | SessionBinaryAssetData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | McpHeadersRefreshRequiredData | McpHeadersRefreshCompletedData | SessionCustomNotificationData | UiEphemeralQueryData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | SessionLimitsExhaustedRequestedData | SessionLimitsExhaustedCompletedData | SessionAutoModeResolvedData | SessionManagedSettingsResolvedData | SessionManagedSettingsEnforcedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | FactoryRunUpdatedData | FactoryRunStartedData | FactoryRunSettledData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | McpToolsListChangedData | McpResourcesListChangedData | McpPromptsListChangedData | SessionExtensionsLoadedData | SessionCanvasOpenedData | SessionCanvasRegistryChangedData | SessionCanvasClosedData | SessionCanvasUnavailableData | SessionCanvasRecordedData | SessionCanvasRemovedData | SessionExtensionsAttachmentsPushedData | McpAppToolCallCompleteData | RawSessionEventData | Data +SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionScheduleCreatedData | SessionScheduleCancelledData | SessionScheduleRearmedData | SessionAutopilotObjectiveChangedData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionModeNoticeDeliveredData | SessionSessionLimitsChangedData | SessionPermissionsChangedData | SessionPlanChangedData | SessionTodosChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionUsageCheckpointData | SessionContextChangedData | SessionUsageInfoData | SessionContextClearedData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | SessionFusionRouteStartedData | SessionFusionRouteFailedData | SessionFusionResolvedData | SessionFusionCompletedData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantTurnRetryData | AgentInterruptedData | AssistantIntentData | AssistantFusionPhaseStartedData | AssistantFusionPhaseCompletedData | AssistantFusionPhaseFailedData | AssistantServerToolProgressData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantToolCallDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantIdleData | AssistantUsageData | PromptCacheBreakData | ModelCallFailureData | ModelCallFinishedData | ModelCallStartData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | ToolSearchActivatedData | SkillInvokedData | SandboxDecisionData | SubagentStartedData | SubagentConfiguredData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | HookProgressData | SessionBinaryAssetData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | McpHeadersRefreshRequiredData | McpHeadersRefreshCompletedData | SessionCustomNotificationData | UiEphemeralQueryData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | SessionLimitsExhaustedRequestedData | SessionLimitsExhaustedCompletedData | SessionAutoModeResolvedData | SessionManagedSettingsResolvedData | SessionManagedSettingsEnforcedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | FactoryRunUpdatedData | FactoryRunStartedData | FactoryRunSettledData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | McpToolsListChangedData | McpResourcesListChangedData | McpPromptsListChangedData | SessionExtensionsLoadedData | SessionCanvasOpenedData | SessionCanvasRegistryChangedData | SessionCanvasClosedData | SessionCanvasUnavailableData | SessionCanvasRecordedData | SessionCanvasRemovedData | SessionExtensionsAttachmentsPushedData | McpAppToolCallCompleteData | RawSessionEventData | Data @dataclass @@ -12374,6 +12406,7 @@ def from_dict(obj: Any) -> "SessionEvent": case SessionEventType.SESSION_WARNING: data = SessionWarningData.from_dict(data_obj) case SessionEventType.SESSION_MODEL_CHANGE: data = SessionModelChangeData.from_dict(data_obj) case SessionEventType.SESSION_MODE_CHANGED: data = SessionModeChangedData.from_dict(data_obj) + case SessionEventType.SESSION_MODE_NOTICE_DELIVERED: data = SessionModeNoticeDeliveredData.from_dict(data_obj) case SessionEventType.SESSION_SESSION_LIMITS_CHANGED: data = SessionSessionLimitsChangedData.from_dict(data_obj) case SessionEventType.SESSION_PERMISSIONS_CHANGED: data = SessionPermissionsChangedData.from_dict(data_obj) case SessionEventType.SESSION_PLAN_CHANGED: data = SessionPlanChangedData.from_dict(data_obj) @@ -12796,6 +12829,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "SessionMcpServersLoadedData", "SessionMode", "SessionModeChangedData", + "SessionModeNoticeDeliveredData", "SessionModelChangeData", "SessionPermissionsChangedData", "SessionPlanChangedData", diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index 00cad43217..a42b9994fc 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -181,6 +181,7 @@ def test_managed_settings_client_provenance_round_trips(self): "server", "device", "client", + "policyHelper", "mixed", "none", ] diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 701ae61403..b1d9896f3e 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -3365,6 +3365,9 @@ pub struct CatalogNetworkFailureError { pub message: String, /// Categorised failure, low cardinality so it can be aggregated without carrying a URL. pub reason: CatalogNetworkFailureReason, + /// Bounded cooldown in seconds before another catalog request should be attempted, when the authority supplied a numeric Retry-After value or the runtime applied its documented fallback. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, /// HTTP status code, when the failure was a rejected response. #[serde(skip_serializing_if = "Option::is_none")] pub status_code: Option, @@ -3427,7 +3430,7 @@ pub struct CatalogSearchRequest { /// Maximum number of candidates to return. Defaults to 10 when omitted. #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, - /// Free-text search query. Never written to logs or telemetry. + /// Free-text search query. Persisted as tool input for session continuity, but omitted from telemetry. pub query: String, } @@ -5621,10 +5624,13 @@ pub struct FactoryResumeRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FactoryRunResult { + /// One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt: Option, /// Error message for an errored run. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, - /// Machine-readable failure details for an errored run. + /// Machine-readable failure details for a halted or errored run. #[serde(skip_serializing_if = "Option::is_none")] pub failure: Option, /// Reason for a halted or cancelled run. @@ -9886,6 +9892,9 @@ pub struct ModelBillingPromo { /// Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it when present. #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, + /// Whether the service asked hosts to give this promotion a prominent surface, such as a dedicated banner, in addition to listing it with the model. `true` requests that surface and `false` asks for the model list only. Absent means the service expressed no preference — for example a response that predates the field — so hosts should apply their own default rather than read it as `false`. + #[serde(skip_serializing_if = "Option::is_none")] + pub show_banner: Option, } /// Long context tier pricing (available for models with extended context windows) @@ -10212,6 +10221,9 @@ pub struct ModelApplyStartupOverlayRequest { /// Model required by device-managed policy, when configured. #[serde(skip_serializing_if = "Option::is_none")] pub device_managed_model: Option, + /// Startup default model from the enterprise policy helper, when configured. Weakest of the managed sources: it applies only when neither device nor server policy names a model, and an explicit user selection still wins. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_model: Option, /// Context tier selected by repository settings, when configured. #[serde(skip_serializing_if = "Option::is_none")] pub repo_context_tier: Option, @@ -18823,6 +18835,9 @@ pub struct SlashCommandCompletedResult { /// Optional user-facing message describing the completed command #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, + /// Optional target session mode applied without submitting an agent prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, /// True when the invocation mutated user runtime settings; consumers caching settings should refresh #[serde(skip_serializing_if = "Option::is_none")] pub runtime_settings_changed: Option, @@ -22388,10 +22403,13 @@ pub struct SessionCanvasActionInvokeResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFactoryRunResult { + /// One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt: Option, /// Error message for an errored run. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, - /// Machine-readable failure details for an errored run. + /// Machine-readable failure details for a halted or errored run. #[serde(skip_serializing_if = "Option::is_none")] pub failure: Option, /// Reason for a halted or cancelled run. @@ -22437,10 +22455,13 @@ pub struct SessionFactoryResumeResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFactoryRunFromToolResult { + /// One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt: Option, /// Error message for an errored run. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, - /// Machine-readable failure details for an errored run. + /// Machine-readable failure details for a halted or errored run. #[serde(skip_serializing_if = "Option::is_none")] pub failure: Option, /// Reason for a halted or cancelled run. @@ -22486,10 +22507,13 @@ pub struct SessionFactoryResumeFromToolResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFactoryGetRunResult { + /// One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt: Option, /// Error message for an errored run. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, - /// Machine-readable failure details for an errored run. + /// Machine-readable failure details for a halted or errored run. #[serde(skip_serializing_if = "Option::is_none")] pub failure: Option, /// Reason for a halted or cancelled run. @@ -22627,10 +22651,13 @@ pub struct SessionFactoryGetRunProgressResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionFactoryCancelResult { + /// One-based execution attempt represented by this envelope. Absent before the first attempt starts or when returned by an older runtime. + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt: Option, /// Error message for an errored run. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, - /// Machine-readable failure details for an errored run. + /// Machine-readable failure details for a halted or errored run. #[serde(skip_serializing_if = "Option::is_none")] pub failure: Option, /// Reason for a halted or cancelled run. @@ -28378,7 +28405,16 @@ pub enum CatalogNetworkFailureReason { /// The connection was refused or reset. #[serde(rename = "connection-refused")] ConnectionRefused, - /// The authority returned a status the runtime treats as a failure. + /// The configured proxy returned 407 and requires authentication. + #[serde(rename = "proxy-authentication-required")] + ProxyAuthenticationRequired, + /// The authority rate-limited requests and supplied or implied a bounded cooldown. + #[serde(rename = "rate-limited")] + RateLimited, + /// The authority returned a transient 5xx response. + #[serde(rename = "service-unavailable")] + ServiceUnavailable, + /// The authority returned another status the runtime treats as a failure. #[serde(rename = "http-status")] HttpStatus, /// The response exceeded the permitted size. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 4b04b03444..8c48ac62ca 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -39,6 +39,8 @@ pub enum SessionEventType { SessionModelChange, #[serde(rename = "session.mode_changed")] SessionModeChanged, + #[serde(rename = "session.mode_notice_delivered")] + SessionModeNoticeDelivered, #[serde(rename = "session.session_limits_changed")] SessionSessionLimitsChanged, /// @@ -465,6 +467,8 @@ pub enum SessionEventData { SessionModelChange(SessionModelChangeData), #[serde(rename = "session.mode_changed")] SessionModeChanged(SessionModeChangedData), + #[serde(rename = "session.mode_notice_delivered")] + SessionModeNoticeDelivered(SessionModeNoticeDeliveredData), #[serde(rename = "session.session_limits_changed")] SessionSessionLimitsChanged(SessionSessionLimitsChangedData), /// @@ -1235,6 +1239,17 @@ pub struct SessionModeChangedData { pub previous_mode: SessionMode, } +/// Session event "session.mode_notice_delivered". Records that a mode transition notice reached the model so cache-stable mode tools can remain offered across resume. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionModeNoticeDeliveredData { + /// Model-visible transition notice persisted for a mid-turn delivery + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Mode established by the delivered transition notice + pub mode: SessionMode, +} + /// Session event "session.session_limits_changed". Session limits update details. Null clears the limits. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2499,7 +2514,7 @@ pub struct FusionAttribution { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AssistantMessageReasoningBlocks { - /// Provider-native reasoning content blocks (e.g. Anthropic `thinking` / `redacted_thinking`) preserved verbatim, in order. A single response can carry several, each signed over the content preceding it, so dropping or reordering any of them invalidates the rest. + /// Provider-native reasoning items or content blocks preserved verbatim, in order. A single response can carry several, and provider signatures or identifiers may depend on their exact content and ordering. #[serde(skip_serializing_if = "Option::is_none")] pub blocks: Option>, /// Model provider that produced these reasoning blocks. @@ -5711,7 +5726,7 @@ pub struct SessionAutoModeResolvedData { pub sticky_override: Option, } -/// Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values per ordinary key, while permissions compose restrictively across device, server, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. +/// Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and which channels contributed, so SDK clients can show users what is enterprise-managed. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively across device, server, policy-helper, and SDK-client layers. The account-scoped `getManagedSettings()` API does not include session-local client injection. Marked experimental while the managed-settings surface stabilizes. /// ///
/// @@ -5736,6 +5751,9 @@ pub struct SessionManagedSettingsResolvedData { /// Whether at least two managed sources supplied permission allowlists, so enforcement intersects them and the flattened settings payload omits `permissions.allow`. #[serde(skip_serializing_if = "Option::is_none")] pub permissions_allow_intersected: Option, + /// Whether the policy-helper managed-settings layer was present. The policy helper is the weakest channel: it fills keys no enterprise source set and can never replace one. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_helper_managed: Option, /// Whether the effective sandbox policy forces the sandbox on *only* because managed policy could not be determined, rather than because the policy requires it. Lets clients tell a user whose `--no-sandbox` was overridden that the sandbox stayed on as a fail-closed fallback, instead of attributing it to an administrator who set no such policy. #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_enabled_by_undetermined_policy: Option, @@ -5744,7 +5762,7 @@ pub struct SessionManagedSettingsResolvedData { /// The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. #[serde(skip_serializing_if = "Option::is_none")] pub settings: Option, - /// Channel summary: `server`, `device`, or `client` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. + /// Channel summary: `server`, `device`, `client`, or `policyHelper` when exactly one channel contributed; `mixed` when multiple channels contributed; otherwise `none`. Consult the per-channel booleans for exact provenance. pub source: ManagedSettingsResolvedSource, } @@ -8158,7 +8176,10 @@ pub enum ManagedSettingsResolvedSource { /// Only session-local SDK-host injection contributed. #[serde(rename = "client")] Client, - /// More than one channel contributed. Ordinary keys resolve device over server per key, while permissions compose restrictively across all present layers. + /// A policy helper registered by device or server policy contributed. Device registration takes priority when present. + #[serde(rename = "policyHelper")] + PolicyHelper, + /// More than one channel contributed. Ordinary keys resolve device over server over policy helper per key, while permissions compose restrictively across all present layers. #[serde(rename = "mixed")] Mixed, /// No managed policy is in force (no channel contributed). diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index a83d73d0a9..274e70f675 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.83-0", + "@github/copilot": "^1.0.83-1", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -472,8 +472,8 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.83-0", - "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", + "version": "1.0.83-1", + "integrity": "sha512-7rhgbgSx7IgfFYXrTx2/2+qowv6MZjC6BN0mhba37RuGQxsca9Zk2ucAeIX3GbQTp91AhvnN0aHCxabX36xADQ==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { @@ -483,19 +483,19 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.83-0", - "@github/copilot-darwin-x64": "1.0.83-0", - "@github/copilot-linux-arm64": "1.0.83-0", - "@github/copilot-linux-x64": "1.0.83-0", - "@github/copilot-linuxmusl-arm64": "1.0.83-0", - "@github/copilot-linuxmusl-x64": "1.0.83-0", - "@github/copilot-win32-arm64": "1.0.83-0", - "@github/copilot-win32-x64": "1.0.83-0" + "@github/copilot-darwin-arm64": "1.0.83-1", + "@github/copilot-darwin-x64": "1.0.83-1", + "@github/copilot-linux-arm64": "1.0.83-1", + "@github/copilot-linux-x64": "1.0.83-1", + "@github/copilot-linuxmusl-arm64": "1.0.83-1", + "@github/copilot-linuxmusl-x64": "1.0.83-1", + "@github/copilot-win32-arm64": "1.0.83-1", + "@github/copilot-win32-x64": "1.0.83-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", + "version": "1.0.83-1", + "integrity": "sha512-gxYJEd4yoIBIQHRmJw6ZegSkR6VuNm7AJVdAdARcU0SS68VZjtZFT29luThAp75Pxp37S5w+3A/jQNtxGyHizw==", "cpu": [ "arm64" ], @@ -510,8 +510,8 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.83-0", - "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", + "version": "1.0.83-1", + "integrity": "sha512-PL63+Ib1RH+Bpu2UYJ9E8tmD4NZD/YlhD25F1Q9Rb86AdgLEoRYlxjNxXXF9P9dMmJ/u5zm0rUtWIRxv/FRbPg==", "cpu": [ "x64" ], @@ -526,8 +526,8 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", + "version": "1.0.83-1", + "integrity": "sha512-7c4RVEuXQu9zepAWoXwkizpESztUbWUR1hLPl+o+19L+D8fXm+5XQRH5E22Ia7P7/xHW3Ml2o2x5cXFq0k7/7w==", "cpu": [ "arm64" ], @@ -542,8 +542,8 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.83-0", - "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", + "version": "1.0.83-1", + "integrity": "sha512-Be0zUVysLYV66pnOPhMxpShQ8Ox5V07qcsSOoNzG7iwpzkfZuf70U9CHk/fnI//mWwmZR+caPUfVM9BpwrdNnA==", "cpu": [ "x64" ], @@ -558,8 +558,8 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", + "version": "1.0.83-1", + "integrity": "sha512-PZNP8vZZJU8yIc3dNjGUBUCFPNjqZs8FPZLDxvL6XdHM7Ep4cLBa+QdbRsw+nZONIaG0GtQf26eqk5ymYYpUPA==", "cpu": [ "arm64" ], @@ -574,8 +574,8 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.83-0", - "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", + "version": "1.0.83-1", + "integrity": "sha512-uKMhGtg5K07nA5/pxDSq8S5F/I+sMgVhUXg90Y4BI9trOidgxpXpCVyh4TeRNj2ENNugPvATdfGc6eFPsjmlgQ==", "cpu": [ "x64" ], @@ -590,8 +590,8 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.83-0", - "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", + "version": "1.0.83-1", + "integrity": "sha512-vMxHTmv3SotBuNI4LKcIFckd+n0OvhTik27qjsqOnVqFpvmHDghSsa8YPxySyxUQIE35z5pJaivVBK3dfEAY7w==", "cpu": [ "arm64" ], @@ -606,8 +606,8 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.83-0", - "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", + "version": "1.0.83-1", + "integrity": "sha512-2l0VVTazW3/+p94ZQ5sAPCj1iH7KWxJAYh4yXWfvpsB/1JEEmyVgpLei1MeaMFVdtp/hi9bGprZ9+zWNlHh5hQ==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index c2081214ab..e293910938 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -14,7 +14,7 @@ "node": "^20.19.0 || >=22.12.0" }, "devDependencies": { - "@github/copilot": "^1.0.83-0", + "@github/copilot": "^1.0.83-1", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14",