From b6bb031d2d4dc67345b054119f39142edff0bcaf Mon Sep 17 00:00:00 2001 From: "copilot-cli-release-app[bot]" Date: Wed, 30 Sep 2026 23:19:24 +0000 Subject: [PATCH 1/5] Update SDK snapshot for Copilot CLI 1.0.91-0 --- .github/workflows/sdk-java.yml | 2 - CONTRIBUTING.md | 3 + dotnet/src/Client.cs | 2 +- dotnet/src/Generated/Rpc.cs | 496 +++++++++++++-- dotnet/src/Generated/SessionEvents.cs | 4 +- dotnet/test/E2E/McpOAuthE2ETests.cs | 82 ++- dotnet/test/E2E/SubagentHooksE2ETests.cs | 50 +- dotnet/test/Harness/E2ETestContext.cs | 65 +- dotnet/test/Harness/E2ETestFixture.cs | 5 +- dotnet/test/Harness/ReplayProxy.cs | 13 +- .../test/Unit/ClientSessionLifetimeTests.cs | 69 +- dotnet/test/Unit/E2ETestFixtureTests.cs | 92 +++ go/internal/e2e/subagent_hooks_e2e_test.go | 44 +- go/rpc/managed_settings_test.go | 56 ++ go/rpc/zrpc.go | 362 ++++++++++- go/rpc/zsession_events.go | 64 +- .../SessionManagedSettingsResolvedEvent.java | 2 +- .../generated/rpc/ManagedSettingMeta.java | 33 + .../generated/rpc/ManagedSettingsChannel.java | 41 ++ .../rpc/ManagedSettingsComposeLayer.java | 33 + .../rpc/ManagedSettingsComposeParams.java | 31 + .../rpc/ManagedSettingsComposeResult.java | 39 ++ .../rpc/ManagedSettingsDiagnostic.java | 35 ++ .../ManagedSettingsDiagnosticSeverity.java | 39 ++ .../generated/rpc/ManagedSettingsLayer.java | 33 + .../generated/rpc/ManagedSettingsMeta.java | 33 + .../rpc/ManagedSettingsResolveParams.java | 34 + .../rpc/ManagedSettingsResolveResult.java | 41 ++ .../rpc/ManagedSettingsResolvedData.java | 52 ++ .../rpc/ManagedSettingsSchemaResult.java | 32 + .../rpc/ManagedSettingsValidateParams.java | 32 + .../rpc/ManagedSettingsValidateResult.java | 35 ++ .../generated/rpc/ManagedSettingsValues.java | 33 + .../rpc/ServerManagedSettingsApi.java | 57 ++ .../rpc/SessionManagedSettingsGetResult.java | 2 +- .../com/github/copilot/CopilotClient.java | 20 +- .../com/github/copilot/CopilotSession.java | 3 +- .../com/github/copilot/CopilotClientTest.java | 113 ++++ .../github/copilot/CopilotSessionTest.java | 3 +- .../com/github/copilot/ErrorHandlingTest.java | 3 +- .../copilot/SessionEventHandlingTest.java | 30 + .../github/copilot/StreamingFidelityTest.java | 3 +- .../github/copilot/SubagentHooksE2ETest.java | 52 +- nodejs/package.json | 2 +- nodejs/src/cliVersion.ts | 2 +- nodejs/src/client.ts | 14 +- nodejs/src/ffiRuntimeHost.ts | 19 +- nodejs/src/generated/rpc.ts | 285 ++++++++- nodejs/src/generated/session-events.ts | 4 +- nodejs/test/client.test.ts | 214 ++++++- nodejs/test/e2e/client.e2e.test.ts | 28 +- nodejs/test/e2e/rpc_server.e2e.test.ts | 56 ++ .../test/e2e/rpc_surface_coverage.e2e.test.ts | 2 +- nodejs/test/e2e/subagent_hooks.e2e.test.ts | 40 +- nodejs/test/ffiRuntimeHost.test.ts | 182 +++++- python/copilot/_ffi_runtime_host.py | 3 +- python/copilot/client.py | 7 +- python/copilot/generated/rpc.py | 595 +++++++++++++++++- python/copilot/generated/session_events.py | 2 +- python/e2e/test_client_e2e.py | 52 +- python/e2e/test_inprocess_ffi_e2e.py | 7 +- python/e2e/test_subagent_hooks_e2e.py | 53 +- python/test_ffi_runtime_host.py | 44 ++ .../tests/test_installation_confirmation.py | 50 +- rust/Cargo.toml | 4 + rust/clippy.toml | 4 + rust/src/ahp_host/tests.rs | 17 +- rust/src/ffi.rs | 3 + rust/src/ffi/shutdown_tests.rs | 92 +++ rust/src/generated/api_types.rs | 326 +++++++++- rust/src/generated/rpc.rs | 147 ++++- rust/src/generated/session_events.rs | 2 +- rust/src/lib.rs | 7 +- rust/src/session.rs | 11 +- rust/tests/e2e/canvas.rs | 16 + rust/tests/e2e/runtime_host_support.rs | 13 +- rust/tests/e2e/subagent_hooks.rs | 18 +- scripts/codegen/go.ts | 6 + 78 files changed, 4269 insertions(+), 331 deletions(-) create mode 100644 go/rpc/managed_settings_test.go create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingMeta.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsChannel.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeLayer.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeResult.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnostic.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnosticSeverity.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsLayer.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsMeta.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveResult.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolvedData.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsSchemaResult.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateResult.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValues.java create mode 100644 rust/src/ffi/shutdown_tests.rs diff --git a/.github/workflows/sdk-java.yml b/.github/workflows/sdk-java.yml index 29927820e2..a69caa71d3 100644 --- a/.github/workflows/sdk-java.yml +++ b/.github/workflows/sdk-java.yml @@ -63,8 +63,6 @@ jobs: run: ./mvnw javadoc:javadoc - if: github.event_name != 'merge_group' && matrix.test-jdk == '25' run: ./mvnw spotless:check - - run: npm ci --ignore-scripts - working-directory: ${{ inputs.sdk-home }}/test/harness - if: matrix.test-jdk == '25' env: CI: "true" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc0fecd18a..ad9514a939 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -276,10 +276,13 @@ export COPILOT_CLI_PATH="$(npm --prefix nodejs run --silent prepare:runtime -- - export COPILOT_LEGACY_CLI_PATH="$(npm --prefix nodejs run --silent prepare:runtime -- --print-legacy-path)" npm --prefix nodejs test -- test/e2e/structured_output.e2e.test.ts (cd dotnet && dotnet test test/GitHub.Copilot.SDK.Test.csproj \ + -p:CopilotSkipCliDownload=true \ --filter FullyQualifiedName~StructuredOutputE2ETests) ``` These are shell-local overrides for focused runs, not machine-wide settings. +The .NET flag skips MSBuild's separate release download; the tests use the +prepared runtime from `COPILOT_CLI_PATH`. The facade sets runtime paths only for its own child processes and clears stale or cross-target overrides before building the host CLI. Cross-target CI instead stages explicit artifacts and uses native test commands; do not copy diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 056ee3e554..f43b32234c 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -699,7 +699,7 @@ or IOException if (ctx.FfiHost is { } ffiHost) { - try { ffiHost.Dispose(); } + try { await Task.Run(ffiHost.Dispose).ConfigureAwait(false); } catch (Exception ex) { AddCleanupError(errors, ex, _logger); } _ffiHost = null; } diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index cef0bfba4e..c95a8e6353 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -6769,6 +6769,260 @@ public sealed class ManagedSettingsReadResult public JsonElement? SettingsJson { get; set; } } +/// One validation finding for a managed-settings document. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsDiagnostic +{ + /// Human-readable description of the finding. + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// Dot-separated path of the offending setting, such as `autoTier.overridable`. Empty for the document as a whole. + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + /// Whether the finding rejects the document. + [JsonPropertyName("severity")] + public ManagedSettingsDiagnosticSeverity Severity { get; set; } +} + +/// One managed-settings channel and the document it delivered. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsLayer +{ + /// Validated managed-settings document this channel delivered. Absent when the channel delivered none. + [JsonPropertyName("settings")] + public JsonElement? Settings { get; set; } + + /// Channel identifier: `device` (MDM, plist, registry, or managed file), `server` (account or organization policy), or `policyHelper` (session-local helper output, supported by compose). Treat unknown output values as additional channels; more may be added. + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; +} + +/// Lock state and provenance of one managed setting. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingMeta +{ + /// Whether users and repositories may choose a different value. `false` means policy locks the value. + [JsonPropertyName("overridable")] + public bool Overridable { get; set; } + + /// Channel that supplied this scalar value, matching a `layers[].source`: `device`, `server`, or `policyHelper`. These scalar defaults select one winning channel, not a mixed source. Treat unknown values as additional channels; more may be added. + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; +} + +/// Per-key lock state and provenance for `ManagedSettingsValues`, with the same field names. Producers emit each typed key in values and meta together; both outer objects are omitted when no typed key is set. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsMeta +{ + /// Lock state and provenance of `values.autoTier`. + [JsonPropertyName("autoTier")] + public ManagedSettingMeta? AutoTier { get; set; } + + /// Lock state and provenance of `values.model`. + [JsonPropertyName("model")] + public ManagedSettingMeta? Model { get; set; } +} + +/// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsResolvedData +{ + /// Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + [JsonPropertyName("bypassPermissionsDisabled")] + public bool BypassPermissionsDisabled { get; set; } + + /// Whether a session-local permissions layer injected by the SDK host was present. + [JsonPropertyName("clientManaged")] + public bool? ClientManaged { get; set; } + + /// Whether an actual device MDM/plist/registry/file managed-settings layer was present. + [JsonPropertyName("deviceManaged")] + public bool DeviceManaged { get; set; } + + /// 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. + [JsonPropertyName("failClosed")] + public bool FailClosed { get; set; } + + /// The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. + [JsonPropertyName("managedKeys")] + public IList ManagedKeys { get => field ??= []; set; } + + /// Whether at least two managed sources supplied permission allowlists, so enforcement intersects them and the flattened settings payload omits `permissions.allow`. + [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. + [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. + [JsonPropertyName("sandboxEnabledByUndeterminedPolicy")] + public bool? SandboxEnabledByUndeterminedPolicy { get; set; } + + /// Whether the server (account/org) managed-settings layer was present. + [JsonPropertyName("serverManaged")] + public bool ServerManaged { get; set; } + + /// The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. + [JsonPropertyName("settings")] + public JsonElement? Settings { get; set; } + + /// 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 ManagedSettingsResolvedSource Source { get; set; } +} + +/// Typed effective values of managed settings. Each field mirrors the managed-settings schema key of the same name; more keys are added as they are typed. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsValues +{ + /// Managed Auto routing preference, used when the selected model is `auto`. + [JsonPropertyName("autoTier")] + public AutoTier? AutoTier { get; set; } + + /// Managed default model identifier, as configured. New sessions start with it; it can name a model the account cannot use, so hosts match it against the listed models. + [JsonPropertyName("model")] + public string? Model { get; set; } +} + +/// Effective enterprise managed settings for an account, resolved without a session. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsResolveResult +{ + /// Printable opaque identity of the account the settings were resolved for, suitable for comparison and storage, not an account selectionId. Absent when no account was available, in which case only device policy is reported. + [JsonPropertyName("account")] + public string? Account { get; set; } + + /// Warnings about unavailable policy sources or a failed refresh served from cache. A cached response is not proof of a successful live fetch; `resolved.failClosed` separately describes enforcement. + [JsonPropertyName("diagnostics")] + public IList Diagnostics { get => field ??= []; set; } + + /// Each managed-settings channel consulted, strongest first, with the validated document it delivered before merging. `resolved.settings` is the merged result. More channels may be added over time. + [JsonPropertyName("layers")] + public IList Layers { get => field ??= []; set; } + + /// Per-key lock state and provenance for the entries in `values`, using the same key names. + [JsonPropertyName("meta")] + public ManagedSettingsMeta? Meta { get; set; } + + /// Effective managed settings from the device and account (server) channels, in the same shape as `session.managedSettings.get`, excluding session-local injection. + [JsonPropertyName("resolved")] + public ManagedSettingsResolvedData Resolved { get => field ??= new(); set; } + + /// Typed effective values of managed settings, keyed like the managed-settings schema and already resolved across channels, with the `{ "overridable": ... }` wrapper removed. Present when policy sets at least one typed key. Keys not typed here are available in `resolved.settings`. + [JsonPropertyName("values")] + public ManagedSettingsValues? Values { get; set; } +} + +/// RPC data type for ManagedSettingsResolve operations. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class ManagedSettingsResolveRequest +{ + /// Embedding client identity for server policy requests, as in session creation. Omit for the CLI identity. + [JsonPropertyName("clientName")] + public string? ClientName { get; set; } + + /// GitHub token to resolve instead of the current account. The call fails when the token cannot be resolved. + [JsonPropertyName("gitHubToken")] + public string? GitHubToken { get; set; } + + /// Opaque account identifier returned by `account.getAllUsers`. When omitted, the current account is used, or device policy only when no account is signed in. + [JsonPropertyName("selectionId")] + public string? SelectionId { get; set; } +} + +/// The authoring JSON schema for managed settings recognized by this runtime. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsSchemaResult +{ + /// Version of the runtime that owns this schema. + [JsonPropertyName("runtimeVersion")] + public string RuntimeVersion { get; set; } = string.Empty; + + /// JSON schema (draft 2020-12) with descriptive shared `x-composition` annotations, not a complete runtime composition contract. Model, effortLevel, and contextTier remain coupled; use `managedSettings.compose` for the runtime's effective result. + [JsonPropertyName("schema")] + public JsonElement Schema { get; set; } +} + +/// Result of validating a managed-settings document. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsValidateResult +{ + /// Errors that reject the document and warnings about content the runtime ignores. + [JsonPropertyName("diagnostics")] + public IList Diagnostics { get => field ??= []; set; } + + /// Canonical form of the document the runtime would apply, with unrecognized keys removed. Absent when the document is invalid. + [JsonPropertyName("settings")] + public JsonElement? Settings { get; set; } + + /// Whether the runtime would accept the document within the preview resource limits. Always equals whether `settings` is present. An invalid document is rejected as a whole. + [JsonPropertyName("valid")] + public bool Valid { get; set; } +} + +/// A candidate managed-settings document to validate without applying it. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class ManagedSettingsValidateRequest +{ + /// The document to validate: a JSON object, or a string containing the document's JSON text. Preview documents are limited to 1 MiB and 64 levels of nesting, a stricter resource limit than delivered-policy parsing; violations are returned as diagnostics. + [JsonPropertyName("content")] + public JsonElement Content { get; set; } + + /// Channel the document is meant for (`device`, `server`, or `policyHelper`). Some keys are only honored in some channels; for example, a `policyHelper` registration is ignored in policy-helper output. When omitted, no channel-specific checks run. + [JsonPropertyName("layer")] + public string? Layer { get; set; } +} + +/// The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsComposeResult +{ + /// Warnings about ignored content, with paths prefixed by the channel name. + [JsonPropertyName("diagnostics")] + public IList Diagnostics { get => field ??= []; set; } + + /// Only the supplied channels, strongest first, with canonical documents. Empty canonical documents are represented as absent settings, as in live resolution. + [JsonPropertyName("layers")] + public IList Layers { get => field ??= []; set; } + + /// Per-key lock state and provenance for `values`. + [JsonPropertyName("meta")] + public ManagedSettingsMeta? Meta { get; set; } + + /// Effective managed settings, in the same shape as `session.managedSettings.get`. + [JsonPropertyName("resolved")] + public ManagedSettingsResolvedData Resolved { get => field ??= new(); set; } + + /// Typed effective values, as in `managedSettings.resolve`. + [JsonPropertyName("values")] + public ManagedSettingsValues? Values { get; set; } +} + +/// One candidate channel; absent settings represents a channel that delivered no document. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ManagedSettingsComposeLayer +{ + /// Candidate managed-settings document. Omit when the channel delivered none, as in resolve output. + [JsonPropertyName("settings")] + public JsonElement? Settings { get; set; } + + /// The channel whose candidate document is being supplied. + [JsonPropertyName("source")] + public ManagedSettingsChannel Source { get; set; } +} + +/// Candidate managed-settings documents to merge without applying them. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class ManagedSettingsComposeRequest +{ + /// One entry per channel. `source` must be `device`, `server`, or `policyHelper`, each at most once (checked at runtime); order does not matter, because channel precedence is fixed. To preview documents from resolve output, map recognized source strings to ManagedSettingsChannel and copy their settings; generated resolve and compose layer types are distinct. Omitted settings means this channel delivered no document. Supplied documents must be valid within the preview limits; warnings are returned in diagnostics. Compose does not reproduce source-failure state or retained enforcement floors from resolve. + [JsonPropertyName("layers")] + public IList Layers { get => field ??= []; set; } +} + /// Indicates whether the calling client was registered as the session filesystem provider. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class SessionFsSetProviderResult @@ -16391,55 +16645,6 @@ internal sealed class SessionConnectorsWithdrawProjectionRequest public string SessionId { get; set; } = string.Empty; } -/// 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(global::GitHub.Copilot.Diagnostics.Experimental)] -public sealed class ManagedSettingsResolvedData -{ - /// Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. - [JsonPropertyName("bypassPermissionsDisabled")] - public bool BypassPermissionsDisabled { get; set; } - - /// Whether a session-local permissions layer injected by the SDK host was present. - [JsonPropertyName("clientManaged")] - public bool? ClientManaged { get; set; } - - /// Whether an actual device MDM/plist/registry/file managed-settings layer was present. - [JsonPropertyName("deviceManaged")] - public bool DeviceManaged { get; set; } - - /// 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. - [JsonPropertyName("failClosed")] - public bool FailClosed { get; set; } - - /// The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. - [JsonPropertyName("managedKeys")] - public IList ManagedKeys { get => field ??= []; set; } - - /// Whether at least two managed sources supplied permission allowlists, so enforcement intersects them and the flattened settings payload omits `permissions.allow`. - [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. - [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. - [JsonPropertyName("sandboxEnabledByUndeterminedPolicy")] - public bool? SandboxEnabledByUndeterminedPolicy { get; set; } - - /// Whether the server (account/org) managed-settings layer was present. - [JsonPropertyName("serverManaged")] - public bool ServerManaged { get; set; } - - /// The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. - [JsonPropertyName("settings")] - public JsonElement? Settings { get; set; } - - /// 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 ManagedSettingsResolvedSource Source { get; set; } -} - /// Identifies the target session. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] internal sealed class SessionManagedSettingsGetRequest @@ -30295,6 +30500,135 @@ public override void Write(Utf8JsonWriter writer, SlashCommandKind value, JsonSe } +/// Severity of a managed-settings validation finding. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ManagedSettingsDiagnosticSeverity : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ManagedSettingsDiagnosticSeverity(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// The runtime rejects the document. + public static ManagedSettingsDiagnosticSeverity Error { get; } = new("error"); + + /// The runtime accepts the document but ignores the flagged content. + public static ManagedSettingsDiagnosticSeverity Warning { get; } = new("warning"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ManagedSettingsDiagnosticSeverity left, ManagedSettingsDiagnosticSeverity right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ManagedSettingsDiagnosticSeverity left, ManagedSettingsDiagnosticSeverity right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ManagedSettingsDiagnosticSeverity other && Equals(other); + + /// + public bool Equals(ManagedSettingsDiagnosticSeverity 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 ManagedSettingsDiagnosticSeverity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ManagedSettingsDiagnosticSeverity value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ManagedSettingsDiagnosticSeverity)); + } + } +} + + +/// A channel accepted by managedSettings.compose. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ManagedSettingsChannel : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ManagedSettingsChannel(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Device policy, the strongest channel. + public static ManagedSettingsChannel Device { get; } = new("device"); + + /// Account or organization policy. + public static ManagedSettingsChannel Server { get; } = new("server"); + + /// Session-local helper output, the weakest channel. + public static ManagedSettingsChannel PolicyHelper { get; } = new("policyHelper"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ManagedSettingsChannel left, ManagedSettingsChannel right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ManagedSettingsChannel left, ManagedSettingsChannel right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ManagedSettingsChannel other && Equals(other); + + /// + public bool Equals(ManagedSettingsChannel 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 ManagedSettingsChannel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ManagedSettingsChannel value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ManagedSettingsChannel)); + } + } +} + + /// Path conventions used by this filesystem. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -41688,7 +42022,7 @@ internal ServerManagedSettingsApi(JsonRpc rpc) _rpc = rpc; } - /// Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session. + /// Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session. `managedSettings.resolve` returns the same device settings together with the account's server policy. /// The to monitor for cancellation requests. The default is . /// Validated device-managed settings discovered before a session exists. public async Task ReadAsync(CancellationToken cancellationToken = default) @@ -41696,12 +42030,57 @@ public async Task ReadAsync(CancellationToken cancell return await CopilotClient.InvokeRpcAsync(_rpc, "managedSettings.read", [], cancellationToken); } - /// Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `<cacheHome>/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing "sync account policy" should start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed. + /// Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `<cacheHome>/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing "sync account policy" should call `managedSettings.resolve` or start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed. /// The to monitor for cancellation requests. The default is . public async Task ClearCacheAsync(CancellationToken cancellationToken = default) { await CopilotClient.InvokeRpcAsync(_rpc, "managedSettings.clearCache", [], cancellationToken); } + + /// Resolves the effective enterprise managed settings without a session, from the device channel and, when an account is available, the account's server policy through the same per-account cache sessions use. A cached server policy less than an hour old is used without a fetch; otherwise the policy is fetched, and when the fetch fails a cached policy up to 24 hours old is used instead, unless `forceRemoteSettingsRefresh` requires a live fetch. With no account requested or signed in, it reports device policy only; signing out removes the account's cached policy. It can fetch server policy over the network when the cache is stale, so call it off latency-critical paths such as startup rather than before listing models. The policy helper is not run. `layers` lists each channel's document before merging, and `values` and `meta` carry typed effective values and their lock state for the keys typed so far. + /// Opaque account identifier returned by `account.getAllUsers`. When omitted, the current account is used, or device policy only when no account is signed in. + /// GitHub token to resolve instead of the current account. The call fails when the token cannot be resolved. + /// Embedding client identity for server policy requests, as in session creation. Omit for the CLI identity. + /// The to monitor for cancellation requests. The default is . + /// Effective enterprise managed settings for an account, resolved without a session. + public async Task ResolveAsync(string? selectionId = null, string? gitHubToken = null, string? clientName = null, CancellationToken cancellationToken = default) + { + var request = new ManagedSettingsResolveRequest { SelectionId = selectionId, GitHubToken = gitHubToken, ClientName = clientName }; + return await CopilotClient.InvokeRpcAsync(_rpc, "managedSettings.resolve", [request], cancellationToken); + } + + /// Returns the managed-settings authoring JSON schema with descriptive `x-composition` annotations aligned with the shared settings-engine vocabulary. These annotations are not a complete runtime composition contract: model, effortLevel, and contextTier remain coupled. Use `managedSettings.compose` for the runtime's effective result. Performs no I/O. + /// The to monitor for cancellation requests. The default is . + /// The authoring JSON schema for managed settings recognized by this runtime. + public async Task SchemaAsync(CancellationToken cancellationToken = default) + { + return await CopilotClient.InvokeRpcAsync(_rpc, "managedSettings.schema", [], cancellationToken); + } + + /// Validates a candidate managed-settings document the way the runtime validates delivered policy, without applying it. Reports errors that would reject the document, warnings for content the runtime ignores, and the canonical document it would apply. Document text nested more than 64 levels deep is rejected. Performs no I/O. + /// The document to validate: a JSON object, or a string containing the document's JSON text. Preview documents are limited to 1 MiB and 64 levels of nesting, a stricter resource limit than delivered-policy parsing; violations are returned as diagnostics. + /// Channel the document is meant for (`device`, `server`, or `policyHelper`). Some keys are only honored in some channels; for example, a `policyHelper` registration is ignored in policy-helper output. When omitted, no channel-specific checks run. + /// The to monitor for cancellation requests. The default is . + /// Result of validating a managed-settings document. + public async Task ValidateAsync(object content, string? layer = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(content); + + var request = new ManagedSettingsValidateRequest { Content = CopilotClient.ToJsonElementForWire(content)!.Value, Layer = layer }; + return await CopilotClient.InvokeRpcAsync(_rpc, "managedSettings.validate", [request], cancellationToken); + } + + /// Merges candidate managed-settings documents for the device, server, and policy-helper channels into the effective settings the runtime would enforce on this host, using the same precedence and composition rules as live resolution, without applying them. Like live resolution, a server's advisory sandbox force-enable is declined on a host that cannot run the sandbox. Does not fetch policy or read policy files, but may perform blocking OS or subprocess probes for sandbox support. Preview documents are limited to 1 MiB and 64 levels of nesting. + /// One entry per channel. `source` must be `device`, `server`, or `policyHelper`, each at most once (checked at runtime); order does not matter, because channel precedence is fixed. To preview documents from resolve output, map recognized source strings to ManagedSettingsChannel and copy their settings; generated resolve and compose layer types are distinct. Omitted settings means this channel delivered no document. Supplied documents must be valid within the preview limits; warnings are returned in diagnostics. Compose does not reproduce source-failure state or retained enforcement floors from resolve. + /// The to monitor for cancellation requests. The default is . + /// The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns. + public async Task ComposeAsync(IList layers, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(layers); + + var request = new ManagedSettingsComposeRequest { Layers = layers }; + return await CopilotClient.InvokeRpcAsync(_rpc, "managedSettings.compose", [request], cancellationToken); + } } /// Provides server-scoped Runtime APIs. @@ -45302,7 +45681,7 @@ internal ManagedSettingsApi(CopilotSession session) /// Waits for the live session's in-flight managed-settings application, then returns the retained effective snapshot used by runtime enforcement and by `session.managed_settings_resolved`. It does not perform another account, device, or server resolution, and rejects when resolution has not produced a snapshot. /// The to monitor for cancellation requests. The default is . - /// 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. + /// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. public async Task GetAsync(CancellationToken cancellationToken = default) { _session.ThrowIfDisposed(); @@ -48674,8 +49053,21 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(LogRequest))] [JsonSerializable(typeof(LogResult))] [JsonSerializable(typeof(LspInitializeRequest))] +[JsonSerializable(typeof(ManagedSettingMeta))] +[JsonSerializable(typeof(ManagedSettingsComposeLayer))] +[JsonSerializable(typeof(ManagedSettingsComposeRequest))] +[JsonSerializable(typeof(ManagedSettingsComposeResult))] +[JsonSerializable(typeof(ManagedSettingsDiagnostic))] +[JsonSerializable(typeof(ManagedSettingsLayer))] +[JsonSerializable(typeof(ManagedSettingsMeta))] [JsonSerializable(typeof(ManagedSettingsReadResult))] +[JsonSerializable(typeof(ManagedSettingsResolveRequest))] +[JsonSerializable(typeof(ManagedSettingsResolveResult))] [JsonSerializable(typeof(ManagedSettingsResolvedData))] +[JsonSerializable(typeof(ManagedSettingsSchemaResult))] +[JsonSerializable(typeof(ManagedSettingsValidateRequest))] +[JsonSerializable(typeof(ManagedSettingsValidateResult))] +[JsonSerializable(typeof(ManagedSettingsValues))] [JsonSerializable(typeof(MarketplaceAddResult))] [JsonSerializable(typeof(MarketplaceBrowseResult))] [JsonSerializable(typeof(MarketplaceInfo))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 0edc4fbfcd..11c9635f42 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -1812,7 +1812,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, 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. +/// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. /// Represents the session.managed_settings_resolved event. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed partial class SessionManagedSettingsResolvedEvent : SessionEvent @@ -6433,7 +6433,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, 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. +/// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed partial class SessionManagedSettingsResolvedData { diff --git a/dotnet/test/E2E/McpOAuthE2ETests.cs b/dotnet/test/E2E/McpOAuthE2ETests.cs index 90f2913f9c..b5ce41eaf9 100644 --- a/dotnet/test/E2E/McpOAuthE2ETests.cs +++ b/dotnet/test/E2E/McpOAuthE2ETests.cs @@ -22,6 +22,38 @@ public class McpOAuthE2ETests(E2ETestFixture fixture, ITestOutputHelper output) private const string ReauthToken = ExpectedToken + "-reauth"; private const string CimdUrl = "https://github.com/copilot/cli/client-metadata.json"; + [Fact] + public async Task Should_Reclaim_Server_Process_When_Startup_Is_Canceled() + { + using var process = Process.Start(new ProcessStartInfo("node") + { + Arguments = "-e \"console.log('started'); setInterval(() => {}, 1000)\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + })!; + using var observer = Process.GetProcessById(process.Id); + using var cancellation = new CancellationTokenSource(); + try + { + Assert.Equal("started", await process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(10))); + var startup = OAuthMcpServer.WaitForListeningAsync(process, cancellation.Token); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => startup); + Assert.True(observer.HasExited, "Canceled startup retained the OAuth server process."); + } + finally + { + if (!observer.HasExited) + { + observer.Kill(entireProcessTree: true); + await observer.WaitForExitAsync(); + } + } + } + [Fact] public async Task Should_Use_Cimd_Url_Instead_Of_Dynamic_Registration() { @@ -324,23 +356,53 @@ public static async Task StartAsync(string expectedToken, bool c var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start OAuth MCP server."); - var stderrTask = process.StandardError.ReadToEndAsync(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - while (!cts.IsCancellationRequested) + return await WaitForListeningAsync(process, cts.Token); + } + + public static async Task WaitForListeningAsync(Process process, CancellationToken cancellationToken) + { +#if NET + var stderrTask = process.StandardError.ReadToEndAsync(CancellationToken.None); +#else + var stderrTask = process.StandardError.ReadToEndAsync(); +#endif + try { - var line = await process.StandardOutput.ReadLineAsync(cts.Token); - if (line is null) + while (true) { - throw new InvalidOperationException($"OAuth MCP server exited before listening: {await stderrTask}"); + var line = await process.StandardOutput.ReadLineAsync(cancellationToken); + if (line is null) + { + throw new InvalidOperationException($"OAuth MCP server exited before listening: {await stderrTask}"); + } + if (line.StartsWith("Listening: ", StringComparison.Ordinal)) + { + return new OAuthMcpServer(process, line["Listening: ".Length..]); + } } - if (line.StartsWith("Listening: ", StringComparison.Ordinal)) + } + catch (Exception startupError) + { + try { - return new OAuthMcpServer(process, line["Listening: ".Length..]); + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + await process.WaitForExitAsync(CancellationToken.None); + await stderrTask; + } + catch (Exception cleanupError) + { + throw new AggregateException(startupError, cleanupError); } + finally + { + process.Dispose(); + } + throw; } - - throw new TimeoutException($"Timed out waiting for OAuth MCP server: {await stderrTask}"); } public async Task> GetRequestsAsync() diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs index 8314b8c098..c5d8c80fe2 100644 --- a/dotnet/test/E2E/SubagentHooksE2ETests.cs +++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs @@ -20,6 +20,10 @@ public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_T { var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>(); var requestHandler = new RecordingForwardingRequestHandler(); + const string waitingText = "I've launched an explore agent to read subagent-test.txt. Waiting for it to complete..."; + const string finalText = "The explore agent successfully read the file. The contents of **subagent-test.txt** are:\n\n```\nHello from subagent test!\n```"; + var parentSessionId = Guid.NewGuid().ToString(); + var parentWaiting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Create a client with the session-based subagents feature flag var env = new Dictionary(Ctx.GetEnvironment()); @@ -32,6 +36,7 @@ public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_T var session = await Ctx.CreateSessionAsync(client, new SessionConfig { + SessionId = parentSessionId, OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { @@ -43,10 +48,15 @@ public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_T PermissionDecision = "allow" }); }, - OnPostToolUse = (input, invocation) => + OnPostToolUse = async (input, invocation) => { hookLog.Add(("post", input.ToolName, input.SessionId)); - return Task.FromResult(null); + // A fast child can inject its result before the fixture's waiting reply is requested. + if (input.ToolName == "view" && input.SessionId != parentSessionId) + { + await parentWaiting.Task; + } + return null; }, }, }); @@ -54,14 +64,36 @@ public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_T // Create a file for the sub-agent to read await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "subagent-test.txt"), "Hello from subagent test!"); - await session.SendAndWaitAsync( - new MessageOptions + using var subscription = session.On(message => + { + if (string.IsNullOrEmpty(message.AgentId) && message.Data.Content == waitingText) { - Prompt = "Use the task tool to spawn an explore agent that reads the file " - + "subagent-test.txt in the current directory and reports its contents. " - + "You must use the task tool." - }, - timeout: TimeSpan.FromSeconds(120)); + parentWaiting.TrySetResult(true); + } + }); + try + { + var response = await session.SendAndWaitAsync( + new MessageOptions + { + Prompt = "Use the task tool to spawn an explore agent that reads the file " + + "subagent-test.txt in the current directory and reports its contents. " + + "You must use the task tool." + }, + timeout: TimeSpan.FromSeconds(120)); + Assert.NotNull(response); + Assert.True(string.IsNullOrEmpty(response.AgentId)); + Assert.Equal(finalText, response.Data.Content); + var replies = (await session.GetEventsAsync()).OfType() + .Where(message => string.IsNullOrEmpty(message.AgentId)) + .Select(message => message.Data.Content) + .Where(content => content == waitingText || content == finalText); + Assert.Equal([waitingText, finalText], replies); + } + finally + { + parentWaiting.TrySetResult(true); + } var log = hookLog.ToArray(); diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 2c8b77d435..b399372717 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -20,7 +20,7 @@ public sealed class E2ETestContext : IAsyncDisposable public string HomeDir { get; } public string WorkDir { get; } - public string ProxyUrl { get; } + public string ProxyUrl { get; private set; } internal static bool UsesInProcessTransport => IsInProcess(null); /// Optional logger injected by tests; applied to all clients created via . @@ -47,7 +47,12 @@ private E2ETestContext(string homeDir, string workDir, string proxyUrl, ReplayPr _legacyCliPath = GetCachedCliPath(repoRoot, "--print-legacy-path"); } - public static async Task CreateAsync() + public static Task CreateAsync() => CreateAsync( + new ReplayProxy(), + Path.Combine(Path.GetTempPath(), $"copilot-test-config-{Guid.NewGuid()}"), + Path.Combine(Path.GetTempPath(), $"copilot-test-work-{Guid.NewGuid()}")); + + internal static async Task CreateAsync(ReplayProxy proxy, string homeDir, string workDir) { // A previous in-process context may have left this process's cwd inside a work // directory that has since been deleted. getcwd() then fails, which breaks @@ -57,33 +62,45 @@ public static async Task CreateAsync() var repoRoot = FindRepoRoot(); - var homeDir = Path.Combine(Path.GetTempPath(), $"copilot-test-config-{Guid.NewGuid()}"); - var workDir = Path.Combine(Path.GetTempPath(), $"copilot-test-work-{Guid.NewGuid()}"); - - Directory.CreateDirectory(homeDir); - Directory.CreateDirectory(workDir); - // Resolve symlinks (e.g., macOS /var -> /private/var) so paths // match what spawned subprocesses see when they resolve their cwd. homeDir = ResolveSymlinks(homeDir); workDir = ResolveSymlinks(workDir); - var proxy = new ReplayProxy(); - var proxyUrl = await proxy.StartAsync(); - // Creating an in-process fixture applies this URL before its first - // test-specific configuration is posted, so early runtime requests need - // an empty but valid replay state. - await proxy.ConfigureAsync( - Path.Combine(workDir, "__unconfigured__.yaml"), - workDir, - "capi"); - await proxy.SetCopilotUserByTokenAsync(DefaultGitHubToken, new CopilotUserConfig( - Login: "e2e-test-user", - CopilotPlan: "individual_pro", - Endpoints: new CopilotUserEndpoints(Api: proxyUrl, Telemetry: "https://localhost:1/telemetry"), - AnalyticsTrackingId: "e2e-test-tracking-id", Id: 12345)); - - return new E2ETestContext(homeDir, workDir, proxyUrl, proxy, repoRoot); + var context = new E2ETestContext(homeDir, workDir, string.Empty, proxy, repoRoot); + try + { + Directory.CreateDirectory(homeDir); + Directory.CreateDirectory(workDir); + + context.ProxyUrl = await proxy.StartAsync(); + // Creating an in-process fixture applies this URL before its first + // test-specific configuration is posted, so early runtime requests need + // an empty but valid replay state. + await proxy.ConfigureAsync( + Path.Combine(workDir, "__unconfigured__.yaml"), + workDir, + "capi"); + await proxy.SetCopilotUserByTokenAsync(DefaultGitHubToken, new CopilotUserConfig( + Login: "e2e-test-user", + CopilotPlan: "individual_pro", + Endpoints: new CopilotUserEndpoints(Api: context.ProxyUrl, Telemetry: "https://localhost:1/telemetry"), + AnalyticsTrackingId: "e2e-test-tracking-id", Id: 12345)); + + return context; + } + catch (Exception startupError) + { + try + { + await context.DisposeAsync(); + } + catch (Exception cleanupError) + { + throw new AggregateException(startupError, cleanupError); + } + throw; + } } /// diff --git a/dotnet/test/Harness/E2ETestFixture.cs b/dotnet/test/Harness/E2ETestFixture.cs index e29f5f7f6c..9bc0efcf71 100644 --- a/dotnet/test/Harness/E2ETestFixture.cs +++ b/dotnet/test/Harness/E2ETestFixture.cs @@ -30,6 +30,9 @@ internal static RuntimeConnection CreateSharedConnection(bool useInProcessTransp public async Task DisposeAsync() { - await Ctx.DisposeAsync(); + if (Ctx is not null) + { + await Ctx.DisposeAsync(); + } } } diff --git a/dotnet/test/Harness/ReplayProxy.cs b/dotnet/test/Harness/ReplayProxy.cs index 55a3afae44..5e22bd7d8b 100644 --- a/dotnet/test/Harness/ReplayProxy.cs +++ b/dotnet/test/Harness/ReplayProxy.cs @@ -139,10 +139,17 @@ public async Task StopAsync(bool skipWritingCache = false) catch { /* Best effort */ } } - if (_process is { HasExited: false }) + try { - try { _process.Kill(entireProcessTree: true); await _process.WaitForExitAsync(); } - catch { /* Ignore */ } + if (_process is { HasExited: false }) + { + try { _process.Kill(entireProcessTree: true); await _process.WaitForExitAsync(); } + catch { /* Ignore */ } + } + } + catch (InvalidOperationException) + { + // Process.Start failed before this object was associated with a child. } _process?.Dispose(); diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 4f1a5cc4ea..8038bb2993 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -14,6 +14,7 @@ using GitHub.Copilot.Rpc; using GitHub.Copilot.Test.Harness; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace GitHub.Copilot.Test.Unit; @@ -215,7 +216,7 @@ public async Task StopAsync_Requests_Runtime_Shutdown_For_Owned_Process() await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); using var process = StartExitedProcess(); - await ReplaceConnectionCliProcessAsync(client, process); + await ReplaceConnectionResourcesAsync(client, process); await client.StopAsync(); @@ -229,7 +230,7 @@ public async Task DisposeAsync_Requests_Runtime_Shutdown_For_Owned_Process() var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); using var process = StartExitedProcess(); - await ReplaceConnectionCliProcessAsync(client, process); + await ReplaceConnectionResourcesAsync(client, process); await client.DisposeAsync(); @@ -244,7 +245,7 @@ public async Task StopAsync_Does_Not_Throw_When_Runtime_Shutdown_Fails() await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); using var process = StartExitedProcess(); - await ReplaceConnectionCliProcessAsync(client, process); + await ReplaceConnectionResourcesAsync(client, process); await client.StopAsync(); @@ -258,7 +259,7 @@ public async Task ForceStopAsync_And_External_Stop_Do_Not_Request_Runtime_Shutdo await using var forceClient = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(forceServer.Url) }); await forceClient.StartAsync(); using var process = StartExitedProcess(); - await ReplaceConnectionCliProcessAsync(forceClient, process); + await ReplaceConnectionResourcesAsync(forceClient, process); await forceClient.ForceStopAsync(); @@ -273,6 +274,62 @@ public async Task ForceStopAsync_And_External_Stop_Do_Not_Request_Runtime_Shutdo Assert.Equal(0, externalServer.RuntimeShutdownCount); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Async_Stop_Keeps_Caller_Scheduler_Responsive_During_Native_Shutdown(bool force) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await client.StartAsync(); + var shutdownStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var releaseShutdown = new ManualResetEventSlim(); + var shutdownCalls = 0; + var hostType = typeof(CopilotClient).Assembly.GetType("GitHub.Copilot.FfiRuntimeHost", throwOnError: true)!; + var constructor = hostType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic) + .Single(candidate => candidate.GetParameters().Length == 8); + using var host = (IDisposable)constructor.Invoke( + [ + "test-runtime", + null, + null, + Array.Empty(), + NullLogger.Instance, + new Func(_ => true), + new Func(_ => + { + Interlocked.Increment(ref shutdownCalls); + shutdownStarted.TrySetResult(); + releaseShutdown.Wait(); + return true; + }), + new Action(() => { }), + ]); + hostType.GetField("_serverId", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(host, (uint)11); + await ReplaceConnectionResourcesAsync(client, ffiHost: host); + + var scheduler = new ConcurrentExclusiveSchedulerPair(TaskScheduler.Default, maxConcurrencyLevel: 1); + var factory = new TaskFactory(CancellationToken.None, TaskCreationOptions.None, TaskContinuationOptions.None, scheduler.ExclusiveScheduler); + var stop = factory.StartNew(() => force ? client.ForceStopAsync() : client.StopAsync()).Unwrap(); + try + { + await shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await factory.StartNew(() => { }).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(stop.IsCompleted, "Stop returned before native shutdown completed."); + } + finally + { + releaseShutdown.Set(); + await stop.WaitAsync(TimeSpan.FromSeconds(5)); + scheduler.Complete(); + await scheduler.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + } + + Assert.Equal(1, shutdownCalls); + Assert.Equal(force ? 0 : 1, server.RuntimeShutdownCount); + } + [Fact] public async Task Dropped_Session_Remains_Rooted_By_Client() { @@ -2586,7 +2643,7 @@ private static async Task WaitForRequestAsync(FakeCopilotServe throw new TimeoutException($"Timed out waiting for RPC method '{method}'."); } - private static async Task ReplaceConnectionCliProcessAsync(CopilotClient client, Process process) + private static async Task ReplaceConnectionResourcesAsync(CopilotClient client, Process? process = null, IDisposable? ffiHost = null) { var field = typeof(CopilotClient).GetField("_connectionTask", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("_connectionTask field was not found."); @@ -2600,7 +2657,7 @@ private static async Task ReplaceConnectionCliProcessAsync(CopilotClient client, var rpc = connectionType.GetProperty("Rpc")!.GetValue(connection); var networkStream = connectionType.GetProperty("NetworkStream")!.GetValue(connection); var constructor = connectionType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(); - var updatedConnection = constructor.Invoke([rpc, process, networkStream, null, null]); + var updatedConnection = constructor.Invoke([rpc, process, networkStream, null, ffiHost]); var fromResult = typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(connectionType); field.SetValue(client, fromResult.Invoke(null, [updatedConnection])); } diff --git a/dotnet/test/Unit/E2ETestFixtureTests.cs b/dotnet/test/Unit/E2ETestFixtureTests.cs index f7dee3ce0a..af56bcd3f3 100644 --- a/dotnet/test/Unit/E2ETestFixtureTests.cs +++ b/dotnet/test/Unit/E2ETestFixtureTests.cs @@ -2,12 +2,104 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using System.ComponentModel; +using System.Diagnostics; +using System.Reflection; +using GitHub.Copilot.Test.Harness; using Xunit; namespace GitHub.Copilot.Test.Unit; public class E2ETestFixtureTests { + [Fact] + public async Task Failed_Process_Launch_Reclaims_Context_Directories() + { + var directory = Path.Join(Path.GetTempPath(), $"copilot-failed-launch-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var homeDir = Path.Join(directory, "home"); + var workDir = Path.Join(directory, "work"); + using var process = new Process + { + StartInfo = new ProcessStartInfo(Path.Join(directory, "missing-proxy")) + { + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + await using var proxy = new ReplayProxy(); + try + { + var failure = Assert.Throws(() => process.Start()); + typeof(ReplayProxy).GetField("_process", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(proxy, process); + typeof(ReplayProxy).GetField("_startupTask", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(proxy, Task.FromException(failure)); + + var observed = await Assert.ThrowsAsync( + () => E2ETestContext.CreateAsync(proxy, homeDir, workDir)); + + Assert.Same(failure, observed); + Assert.False(Directory.Exists(homeDir), "Failed launch retained the home directory."); + Assert.False(Directory.Exists(workDir), "Failed launch retained the work directory."); + } + finally + { + typeof(ReplayProxy).GetField("_process", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(proxy, null); + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task Failed_Context_Startup_Reclaims_Proxy_And_Directories() + { + var directory = Path.Join(Path.GetTempPath(), $"copilot-failed-startup-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var homeDir = Path.Join(directory, "home"); + var workDir = Path.Join(directory, "work"); + var startInfo = new ProcessStartInfo("node") + { + Arguments = "-e \"console.log('started'); setInterval(() => {}, 1000)\"", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + using var process = Process.Start(startInfo)!; + using var observer = Process.GetProcessById(process.Id); + await using var proxy = new ReplayProxy(); + var failure = new TimeoutException("controlled proxy startup failure"); + try + { + typeof(ReplayProxy).GetField("_process", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(proxy, process); + typeof(ReplayProxy).GetField("_startupTask", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(proxy, Task.FromException(failure)); + Assert.Equal("started", await process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(10))); + + var observed = await Assert.ThrowsAsync( + () => E2ETestContext.CreateAsync(proxy, homeDir, workDir)); + + Assert.Same(failure, observed); + Assert.False(Directory.Exists(homeDir), "Failed startup retained the home directory."); + Assert.False(Directory.Exists(workDir), "Failed startup retained the work directory."); + Assert.True(observer.HasExited, "Failed startup retained the proxy process."); + } + finally + { + await proxy.StopAsync(skipWritingCache: true); + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task Dispose_Before_Context_Is_Created_Does_Not_Throw() + { + var fixture = new E2ETestFixture(); + + await fixture.DisposeAsync(); + } + [Fact] public void Shared_Client_Uses_InProcess_Connection_For_InProcess_Tests() { diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go index 0e2fde9f86..2f2cedf904 100644 --- a/go/internal/e2e/subagent_hooks_e2e_test.go +++ b/go/internal/e2e/subagent_hooks_e2e_test.go @@ -9,6 +9,7 @@ import ( copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/google/uuid" ) type subagentRequestRecord struct { @@ -96,8 +97,15 @@ func TestSubagentHooksE2E(t *testing.T) { } var hookLog []hookEntry var mu sync.Mutex + const waitingText = "I've launched an explore agent to read subagent-test.txt. Waiting for it to complete..." + const finalText = "The explore agent successfully read the file. The contents of **subagent-test.txt** are:\n\n```\nHello from subagent test!\n```" + parentSessionID := uuid.NewString() + parentWaiting := make(chan struct{}) + releaseView := sync.OnceFunc(func() { close(parentWaiting) }) + defer releaseView() session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + SessionID: parentSessionID, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { @@ -110,6 +118,14 @@ func TestSubagentHooksE2E(t *testing.T) { mu.Lock() hookLog = append(hookLog, hookEntry{kind: "post", toolName: input.ToolName, sessionID: input.SessionID}) mu.Unlock() + // A fast child can inject its result before the fixture's waiting reply is requested. + if input.ToolName == "view" && input.SessionID != parentSessionID { + select { + case <-parentWaiting: + case <-t.Context().Done(): + return nil, t.Context().Err() + } + } return nil, nil }, }, @@ -124,12 +140,38 @@ func TestSubagentHooksE2E(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + unsubscribe := session.On(func(event copilot.SessionEvent) { + if message, ok := event.Data.(*copilot.AssistantMessageData); ok && (event.AgentID == nil || *event.AgentID == "") && message.Content == waitingText { + releaseView() + } + }) + defer unsubscribe() + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and reports its contents. You must use the task tool.", }) if err != nil { t.Fatalf("Failed to send message: %v", err) } + if response == nil { + t.Fatal("Missing parent final response") + } + message, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || (response.AgentID != nil && *response.AgentID != "") || message.Content != finalText { + t.Fatalf("Unexpected parent final response: %+v", response) + } + events, err := session.GetEvents(t.Context()) + if err != nil { + t.Fatalf("Failed to read history: %v", err) + } + var replies []string + for _, event := range events { + if message, ok := event.Data.(*copilot.AssistantMessageData); ok && (event.AgentID == nil || *event.AgentID == "") && (message.Content == waitingText || message.Content == finalText) { + replies = append(replies, message.Content) + } + } + if len(replies) != 2 || replies[0] != waitingText || replies[1] != finalText { + t.Fatalf("Expected durable waiting reply followed by final reply, got %q", replies) + } mu.Lock() defer mu.Unlock() diff --git a/go/rpc/managed_settings_test.go b/go/rpc/managed_settings_test.go new file mode 100644 index 0000000000..34e69401fc --- /dev/null +++ b/go/rpc/managed_settings_test.go @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +package rpc + +import ( + "context" + "encoding/json" + "net" + "testing" + "time" + + "github.com/github/copilot-sdk/go/internal/jsonrpc2" +) + +func TestManagedSettingsResolveRequestEncoding(t *testing.T) { + selectionID := "selected-account" + for _, test := range []struct { + name string + params *ManagedSettingsResolveRequest + want string + }{ + {"nil", nil, `{}`}, + {"empty", &ManagedSettingsResolveRequest{}, `{}`}, + {"selected", &ManagedSettingsResolveRequest{SelectionID: &selectionID}, `{"selectionId":"selected-account"}`}, + } { + t.Run(test.name, func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + client := jsonrpc2.NewClient(clientConn, clientConn) + server := jsonrpc2.NewClient(serverConn, serverConn) + requests := make(chan json.RawMessage, 1) + server.SetRequestHandler("managedSettings.resolve", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + requests <- params + return json.RawMessage(`{}`), nil + }) + client.Start() + server.Start() + defer client.Stop() + defer server.Stop() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := NewServerRPC(client).ManagedSettings.Resolve(ctx, test.params); err != nil { + t.Fatal(err) + } + select { + case got := <-requests: + if string(got) != test.want { + t.Fatalf("params = %s, want %s", got, test.want) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + }) + } +} diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index f2dcb2d760..2d8c190a1e 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -6094,11 +6094,106 @@ type ManagedMCPServerConfig struct { URL string `json:"url"` } +// Lock state and provenance of one managed setting. +// Experimental: ManagedSettingMeta is part of an experimental API and may change or be +// removed. +type ManagedSettingMeta struct { + // Whether users and repositories may choose a different value. `false` means policy locks + // the value. + Overridable bool `json:"overridable"` + // Channel that supplied this scalar value, matching a `layers[].source`: `device`, + // `server`, or `policyHelper`. These scalar defaults select one winning channel, not a + // mixed source. Treat unknown values as additional channels; more may be added. + Source string `json:"source"` +} + // Experimental: ManagedSettingsClearCacheResult is part of an experimental API and may // change or be removed. type ManagedSettingsClearCacheResult struct { } +// One candidate channel; absent settings represents a channel that delivered no document. +// Experimental: ManagedSettingsComposeLayer is part of an experimental API and may change +// or be removed. +type ManagedSettingsComposeLayer struct { + // Candidate managed-settings document. Omit when the channel delivered none, as in resolve + // output. + Settings any `json:"settings,omitempty"` + // The channel whose candidate document is being supplied. + Source ManagedSettingsChannel `json:"source"` +} + +// Candidate managed-settings documents to merge without applying them. +// Experimental: ManagedSettingsComposeRequest is part of an experimental API and may change +// or be removed. +type ManagedSettingsComposeRequest struct { + // One entry per channel. `source` must be `device`, `server`, or `policyHelper`, each at + // most once (checked at runtime); order does not matter, because channel precedence is + // fixed. To preview documents from resolve output, map recognized source strings to + // ManagedSettingsChannel and copy their settings; generated resolve and compose layer types + // are distinct. Omitted settings means this channel delivered no document. Supplied + // documents must be valid within the preview limits; warnings are returned in diagnostics. + // Compose does not reproduce source-failure state or retained enforcement floors from + // resolve. + Layers []ManagedSettingsComposeLayer `json:"layers"` +} + +// The effective managed settings the runtime would enforce for the given documents, in the +// same shape `managedSettings.resolve` returns. +// Experimental: ManagedSettingsComposeResult is part of an experimental API and may change +// or be removed. +type ManagedSettingsComposeResult struct { + // Warnings about ignored content, with paths prefixed by the channel name. + Diagnostics []ManagedSettingsDiagnostic `json:"diagnostics"` + // Only the supplied channels, strongest first, with canonical documents. Empty canonical + // documents are represented as absent settings, as in live resolution. + Layers []ManagedSettingsLayer `json:"layers"` + // Per-key lock state and provenance for `values`. + Meta *ManagedSettingsMeta `json:"meta,omitempty"` + // Effective managed settings, in the same shape as `session.managedSettings.get`. + Resolved ManagedSettingsResolvedData `json:"resolved"` + // Typed effective values, as in `managedSettings.resolve`. + Values *ManagedSettingsValues `json:"values,omitempty"` +} + +// One validation finding for a managed-settings document. +// Experimental: ManagedSettingsDiagnostic is part of an experimental API and may change or +// be removed. +type ManagedSettingsDiagnostic struct { + // Human-readable description of the finding. + Message string `json:"message"` + // Dot-separated path of the offending setting, such as `autoTier.overridable`. Empty for + // the document as a whole. + Path string `json:"path"` + // Whether the finding rejects the document. + Severity ManagedSettingsDiagnosticSeverity `json:"severity"` +} + +// One managed-settings channel and the document it delivered. +// Experimental: ManagedSettingsLayer is part of an experimental API and may change or be +// removed. +type ManagedSettingsLayer struct { + // Validated managed-settings document this channel delivered. Absent when the channel + // delivered none. + Settings any `json:"settings,omitempty"` + // Channel identifier: `device` (MDM, plist, registry, or managed file), `server` (account + // or organization policy), or `policyHelper` (session-local helper output, supported by + // compose). Treat unknown output values as additional channels; more may be added. + Source string `json:"source"` +} + +// Per-key lock state and provenance for `ManagedSettingsValues`, with the same field names. +// Producers emit each typed key in values and meta together; both outer objects are omitted +// when no typed key is set. +// Experimental: ManagedSettingsMeta is part of an experimental API and may change or be +// removed. +type ManagedSettingsMeta struct { + // Lock state and provenance of `values.autoTier`. + AutoTier *ManagedSettingMeta `json:"autoTier,omitempty"` + // Lock state and provenance of `values.model`. + Model *ManagedSettingMeta `json:"model,omitempty"` +} + // Validated device-managed settings discovered before a session exists. // Experimental: ManagedSettingsReadResult is part of an experimental API and may change or // be removed. @@ -6110,16 +6205,12 @@ type ManagedSettingsReadResult struct { SettingsJSON any `json:"settingsJson,omitempty"` } -// 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. +// Effective enterprise managed settings and contributing channels. Session events report +// applied policy; sessionless resolve reports an account/device snapshot, and compose +// reports a non-applying preview of candidate documents. Device values take precedence over +// server values, then the policy helper, per ordinary key, while permissions compose +// restrictively. Session-local SDK-client policy is included only in session results. +// Marked experimental while the managed-settings surface stabilizes. // Experimental: ManagedSettingsResolvedData is part of an experimental API and may change // or be removed. type ManagedSettingsResolvedData struct { @@ -6159,6 +6250,100 @@ type ManagedSettingsResolvedData struct { Source ManagedSettingsResolvedSource `json:"source"` } +// Experimental: ManagedSettingsResolveRequest is part of an experimental API and may change +// or be removed. +type ManagedSettingsResolveRequest struct { + // Embedding client identity for server policy requests, as in session creation. Omit for + // the CLI identity. + ClientName *string `json:"clientName,omitempty"` + // GitHub token to resolve instead of the current account. The call fails when the token + // cannot be resolved. + GitHubToken *string `json:"gitHubToken,omitempty"` + // Opaque account identifier returned by `account.getAllUsers`. When omitted, the current + // account is used, or device policy only when no account is signed in. + SelectionID *string `json:"selectionId,omitempty"` +} + +// Effective enterprise managed settings for an account, resolved without a session. +// Experimental: ManagedSettingsResolveResult is part of an experimental API and may change +// or be removed. +type ManagedSettingsResolveResult struct { + // Printable opaque identity of the account the settings were resolved for, suitable for + // comparison and storage, not an account selectionId. Absent when no account was available, + // in which case only device policy is reported. + Account *string `json:"account,omitempty"` + // Warnings about unavailable policy sources or a failed refresh served from cache. A cached + // response is not proof of a successful live fetch; `resolved.failClosed` separately + // describes enforcement. + Diagnostics []ManagedSettingsDiagnostic `json:"diagnostics"` + // Each managed-settings channel consulted, strongest first, with the validated document it + // delivered before merging. `resolved.settings` is the merged result. More channels may be + // added over time. + Layers []ManagedSettingsLayer `json:"layers"` + // Per-key lock state and provenance for the entries in `values`, using the same key names. + Meta *ManagedSettingsMeta `json:"meta,omitempty"` + // Effective managed settings from the device and account (server) channels, in the same + // shape as `session.managedSettings.get`, excluding session-local injection. + Resolved ManagedSettingsResolvedData `json:"resolved"` + // Typed effective values of managed settings, keyed like the managed-settings schema and + // already resolved across channels, with the `{ "overridable": ... }` wrapper removed. + // Present when policy sets at least one typed key. Keys not typed here are available in + // `resolved.settings`. + Values *ManagedSettingsValues `json:"values,omitempty"` +} + +// The authoring JSON schema for managed settings recognized by this runtime. +// Experimental: ManagedSettingsSchemaResult is part of an experimental API and may change +// or be removed. +type ManagedSettingsSchemaResult struct { + // Version of the runtime that owns this schema. + RuntimeVersion string `json:"runtimeVersion"` + // JSON schema (draft 2020-12) with descriptive shared `x-composition` annotations, not a + // complete runtime composition contract. Model, effortLevel, and contextTier remain + // coupled; use `managedSettings.compose` for the runtime's effective result. + Schema any `json:"schema"` +} + +// A candidate managed-settings document to validate without applying it. +// Experimental: ManagedSettingsValidateRequest is part of an experimental API and may +// change or be removed. +type ManagedSettingsValidateRequest struct { + // The document to validate: a JSON object, or a string containing the document's JSON text. + // Preview documents are limited to 1 MiB and 64 levels of nesting, a stricter resource + // limit than delivered-policy parsing; violations are returned as diagnostics. + Content any `json:"content"` + // Channel the document is meant for (`device`, `server`, or `policyHelper`). Some keys are + // only honored in some channels; for example, a `policyHelper` registration is ignored in + // policy-helper output. When omitted, no channel-specific checks run. + Layer *string `json:"layer,omitempty"` +} + +// Result of validating a managed-settings document. +// Experimental: ManagedSettingsValidateResult is part of an experimental API and may change +// or be removed. +type ManagedSettingsValidateResult struct { + // Errors that reject the document and warnings about content the runtime ignores. + Diagnostics []ManagedSettingsDiagnostic `json:"diagnostics"` + // Canonical form of the document the runtime would apply, with unrecognized keys removed. + // Absent when the document is invalid. + Settings any `json:"settings,omitempty"` + // Whether the runtime would accept the document within the preview resource limits. Always + // equals whether `settings` is present. An invalid document is rejected as a whole. + Valid bool `json:"valid"` +} + +// Typed effective values of managed settings. Each field mirrors the managed-settings +// schema key of the same name; more keys are added as they are typed. +// Experimental: ManagedSettingsValues is part of an experimental API and may change or be +// removed. +type ManagedSettingsValues struct { + // Managed Auto routing preference, used when the selected model is `auto`. + AutoTier *AutoTier `json:"autoTier,omitempty"` + // Managed default model identifier, as configured. New sessions start with it; it can name + // a model the account cannot use, so hosts match it against the listed models. + Model *string `json:"model,omitempty"` +} + // Result of registering a new marketplace. // Experimental: MarketplaceAddResult is part of an experimental API and may change or be // removed. @@ -22513,6 +22698,32 @@ const ( LoginProviderKindProxima LoginProviderKind = "proxima" ) +// A channel accepted by managedSettings.compose. +// Experimental: ManagedSettingsChannel is part of an experimental API and may change or be +// removed. +type ManagedSettingsChannel string + +const ( + // Device policy, the strongest channel. + ManagedSettingsChannelDevice ManagedSettingsChannel = "device" + // Session-local helper output, the weakest channel. + ManagedSettingsChannelPolicyHelper ManagedSettingsChannel = "policyHelper" + // Account or organization policy. + ManagedSettingsChannelServer ManagedSettingsChannel = "server" +) + +// Severity of a managed-settings validation finding. +// Experimental: ManagedSettingsDiagnosticSeverity is part of an experimental API and may +// change or be removed. +type ManagedSettingsDiagnosticSeverity string + +const ( + // The runtime rejects the document. + ManagedSettingsDiagnosticSeverityError ManagedSettingsDiagnosticSeverity = "error" + // The runtime accepts the document but ignores the flagged content. + ManagedSettingsDiagnosticSeverityWarning ManagedSettingsDiagnosticSeverity = "warning" +) + // Summary of which managed-settings channels contributed to the effective session policy. // Use the per-channel booleans for exact provenance. // Experimental: ManagedSettingsResolvedSource is part of an experimental API and may change @@ -26132,11 +26343,11 @@ type ServerManagedSettingsAPI serverAPI // account, that resolution re-fetches the account's org policy from the network instead of // serving a cached response. Note that `managedSettings.read` returns only device/MDM // settings and never triggers the account server-policy fetch, so a host implementing "sync -// account policy" should start a fresh session resolution rather than treat a subsequent -// `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out -// performs, broadened from the one signing-out account to all of them; device/MDM layers -// describe the machine, not the account, and are left untouched. Rejects if the on-disk -// cache cannot be removed. +// account policy" should call `managedSettings.resolve` or start a fresh session resolution +// rather than treat a subsequent `managedSettings.read` as the refreshed org policy. +// Mirrors the invalidation a sign-out performs, broadened from the one signing-out account +// to all of them; device/MDM layers describe the machine, not the account, and are left +// untouched. Rejects if the on-disk cache cannot be removed. // // RPC method: managedSettings.clearCache. func (a *ServerManagedSettingsAPI) ClearCache(ctx context.Context) (*ManagedSettingsClearCacheResult, error) { @@ -26151,9 +26362,36 @@ func (a *ServerManagedSettingsAPI) ClearCache(ctx context.Context) (*ManagedSett return &result, nil } +// Compose merges candidate managed-settings documents for the device, server, and +// policy-helper channels into the effective settings the runtime would enforce on this +// host, using the same precedence and composition rules as live resolution, without +// applying them. Like live resolution, a server's advisory sandbox force-enable is declined +// on a host that cannot run the sandbox. Does not fetch policy or read policy files, but +// may perform blocking OS or subprocess probes for sandbox support. Preview documents are +// limited to 1 MiB and 64 levels of nesting. +// +// RPC method: managedSettings.compose. +// +// Parameters: Candidate managed-settings documents to merge without applying them. +// +// Returns: The effective managed settings the runtime would enforce for the given +// documents, in the same shape `managedSettings.resolve` returns. +func (a *ServerManagedSettingsAPI) Compose(ctx context.Context, params *ManagedSettingsComposeRequest) (*ManagedSettingsComposeResult, error) { + raw, err := a.client.Request(ctx, "managedSettings.compose", params) + if err != nil { + return nil, err + } + var result ManagedSettingsComposeResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Read discovers device-managed settings from production MDM and managed-file sources, // validates them against the runtime-owned managed-settings schema, and returns the -// canonical JSON without requiring a session. +// canonical JSON without requiring a session. `managedSettings.resolve` returns the same +// device settings together with the account's server policy. // // RPC method: managedSettings.read. // @@ -26170,6 +26408,82 @@ func (a *ServerManagedSettingsAPI) Read(ctx context.Context) (*ManagedSettingsRe return &result, nil } +// Resolves the effective enterprise managed settings without a session, from the device +// channel and, when an account is available, the account's server policy through the same +// per-account cache sessions use. A cached server policy less than an hour old is used +// without a fetch; otherwise the policy is fetched, and when the fetch fails a cached +// policy up to 24 hours old is used instead, unless `forceRemoteSettingsRefresh` requires a +// live fetch. With no account requested or signed in, it reports device policy only; +// signing out removes the account's cached policy. It can fetch server policy over the +// network when the cache is stale, so call it off latency-critical paths such as startup +// rather than before listing models. The policy helper is not run. `layers` lists each +// channel's document before merging, and `values` and `meta` carry typed effective values +// and their lock state for the keys typed so far. +// +// RPC method: managedSettings.resolve. +// +// Parameters: Optional opaque account selection or GitHub token whose managed settings are +// resolved. +// +// Returns: Effective enterprise managed settings for an account, resolved without a session. +func (a *ServerManagedSettingsAPI) Resolve(ctx context.Context, params *ManagedSettingsResolveRequest) (*ManagedSettingsResolveResult, error) { + if params == nil { + params = &ManagedSettingsResolveRequest{} + } + raw, err := a.client.Request(ctx, "managedSettings.resolve", params) + if err != nil { + return nil, err + } + var result ManagedSettingsResolveResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Schema returns the managed-settings authoring JSON schema with descriptive +// `x-composition` annotations aligned with the shared settings-engine vocabulary. These +// annotations are not a complete runtime composition contract: model, effortLevel, and +// contextTier remain coupled. Use `managedSettings.compose` for the runtime's effective +// result. Performs no I/O. +// +// RPC method: managedSettings.schema. +// +// Returns: The authoring JSON schema for managed settings recognized by this runtime. +func (a *ServerManagedSettingsAPI) Schema(ctx context.Context) (*ManagedSettingsSchemaResult, error) { + raw, err := a.client.Request(ctx, "managedSettings.schema", nil) + if err != nil { + return nil, err + } + var result ManagedSettingsSchemaResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Validates a candidate managed-settings document the way the runtime validates delivered +// policy, without applying it. Reports errors that would reject the document, warnings for +// content the runtime ignores, and the canonical document it would apply. Document text +// nested more than 64 levels deep is rejected. Performs no I/O. +// +// RPC method: managedSettings.validate. +// +// Parameters: A candidate managed-settings document to validate without applying it. +// +// Returns: Result of validating a managed-settings document. +func (a *ServerManagedSettingsAPI) Validate(ctx context.Context, params *ManagedSettingsValidateRequest) (*ManagedSettingsValidateResult, error) { + raw, err := a.client.Request(ctx, "managedSettings.validate", params) + if err != nil { + return nil, err + } + var result ManagedSettingsValidateResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Experimental: ServerMCPAPI contains experimental APIs that may change or be removed. type ServerMCPAPI serverAPI @@ -30036,16 +30350,12 @@ type ManagedSettingsAPI sessionAPI // // RPC method: session.managedSettings.get. // -// Returns: 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. +// Returns: Effective enterprise managed settings and contributing channels. Session events +// report applied policy; sessionless resolve reports an account/device snapshot, and +// compose reports a non-applying preview of candidate documents. Device values take +// precedence over server values, then the policy helper, per ordinary key, while +// permissions compose restrictively. Session-local SDK-client policy is included only in +// session results. Marked experimental while the managed-settings surface stabilizes. func (a *ManagedSettingsAPI) Get(ctx context.Context) (*ManagedSettingsResolvedData, error) { req := map[string]any{"sessionId": a.sessionID} raw, err := a.client.Request(ctx, "session.managedSettings.get", req) diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 9962299454..16de7a795f 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -825,6 +825,38 @@ func (*MCPHeadersRefreshRequiredData) Type() SessionEventType { return SessionEventTypeMCPHeadersRefreshRequired } +// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. 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. + BypassPermissionsDisabled bool `json:"bypassPermissionsDisabled"` + // Whether a session-local permissions layer injected by the SDK host was present + ClientManaged *bool `json:"clientManaged,omitempty"` + // Whether an actual device MDM/plist/registry/file managed-settings layer was present + DeviceManaged bool `json:"deviceManaged"` + // 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. + FailClosed bool `json:"failClosed"` + // The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. + 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`, `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"` +} + +func (*SessionManagedSettingsResolvedData) sessionEventData() {} +func (*SessionManagedSettingsResolvedData) Type() SessionEventType { + return SessionEventTypeSessionManagedSettingsResolved +} + // Elicitation request completion with the user's response type ElicitationCompletedData struct { // The user action: "accept" (submitted form), "decline" (explicitly refused), or "cancel" (dismissed) @@ -884,38 +916,6 @@ 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, 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. - BypassPermissionsDisabled bool `json:"bypassPermissionsDisabled"` - // Whether a session-local permissions layer injected by the SDK host was present - ClientManaged *bool `json:"clientManaged,omitempty"` - // Whether an actual device MDM/plist/registry/file managed-settings layer was present - DeviceManaged bool `json:"deviceManaged"` - // 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. - FailClosed bool `json:"failClosed"` - // The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. - 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`, `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"` -} - -func (*SessionManagedSettingsResolvedData) sessionEventData() {} -func (*SessionManagedSettingsResolvedData) Type() SessionEventType { - return SessionEventTypeSessionManagedSettingsResolved -} - // Ephemeral invalidation signal for a changed workflow run. // Experimental: WorkflowRunUpdatedData is part of an experimental API and may change or be removed. type WorkflowRunUpdatedData struct { 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 e5428d26db..d495ec8cf6 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, 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. + * Session event "session.managed_settings_resolved". Effective enterprise managed settings applied to the session and their contributing channels. Emitted whenever managed policy is applied or reapplied, including session start, resume, and account switch. This ephemeral live snapshot is delivered to subscribers but not persisted to the session event log; initial resolution occurs before session.start. * @since 1.0.0 */ @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingMeta.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingMeta.java new file mode 100644 index 0000000000..46967ea441 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingMeta.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * Lock state and provenance of one managed setting. + * + * @apiNote This type 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 ManagedSettingMeta( + /** Whether users and repositories may choose a different value. `false` means policy locks the value. */ + @JsonProperty("overridable") Boolean overridable, + /** Channel that supplied this scalar value, matching a `layers[].source`: `device`, `server`, or `policyHelper`. These scalar defaults select one winning channel, not a mixed source. Treat unknown values as additional channels; more may be added. */ + @JsonProperty("source") String source +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsChannel.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsChannel.java new file mode 100644 index 0000000000..1ab915c122 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsChannel.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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 javax.annotation.processing.Generated; + +/** + * A channel accepted by managedSettings.compose. + * + * @apiNote This type is experimental and may change in a future version. + * + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum ManagedSettingsChannel { + /** The {@code device} variant. */ + DEVICE("device"), + /** The {@code server} variant. */ + SERVER("server"), + /** The {@code policyHelper} variant. */ + POLICYHELPER("policyHelper"); + + private final String value; + ManagedSettingsChannel(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static ManagedSettingsChannel fromValue(String value) { + for (ManagedSettingsChannel v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown ManagedSettingsChannel value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeLayer.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeLayer.java new file mode 100644 index 0000000000..b6267f167b --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeLayer.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * One candidate channel; absent settings represents a channel that delivered no document. + * + * @apiNote This type 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 ManagedSettingsComposeLayer( + /** The channel whose candidate document is being supplied. */ + @JsonProperty("source") ManagedSettingsChannel source, + /** Candidate managed-settings document. Omit when the channel delivered none, as in resolve output. */ + @JsonProperty("settings") Object settings +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeParams.java new file mode 100644 index 0000000000..ca313ce283 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeParams.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import javax.annotation.processing.Generated; + +/** + * Candidate managed-settings documents to merge without applying them. + * + * @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 ManagedSettingsComposeParams( + /** One entry per channel. `source` must be `device`, `server`, or `policyHelper`, each at most once (checked at runtime); order does not matter, because channel precedence is fixed. To preview documents from resolve output, map recognized source strings to ManagedSettingsChannel and copy their settings; generated resolve and compose layer types are distinct. Omitted settings means this channel delivered no document. Supplied documents must be valid within the preview limits; warnings are returned in diagnostics. Compose does not reproduce source-failure state or retained enforcement floors from resolve. */ + @JsonProperty("layers") List layers +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeResult.java new file mode 100644 index 0000000000..37f3568360 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsComposeResult.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import javax.annotation.processing.Generated; + +/** + * The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns. + * + * @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 ManagedSettingsComposeResult( + /** Effective managed settings, in the same shape as `session.managedSettings.get`. */ + @JsonProperty("resolved") ManagedSettingsResolvedData resolved, + /** Typed effective values, as in `managedSettings.resolve`. */ + @JsonProperty("values") ManagedSettingsValues values, + /** Per-key lock state and provenance for `values`. */ + @JsonProperty("meta") ManagedSettingsMeta meta, + /** Only the supplied channels, strongest first, with canonical documents. Empty canonical documents are represented as absent settings, as in live resolution. */ + @JsonProperty("layers") List layers, + /** Warnings about ignored content, with paths prefixed by the channel name. */ + @JsonProperty("diagnostics") List diagnostics +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnostic.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnostic.java new file mode 100644 index 0000000000..22068ba231 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnostic.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * One validation finding for a managed-settings document. + * + * @apiNote This type 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 ManagedSettingsDiagnostic( + /** Dot-separated path of the offending setting, such as `autoTier.overridable`. Empty for the document as a whole. */ + @JsonProperty("path") String path, + /** Whether the finding rejects the document. */ + @JsonProperty("severity") ManagedSettingsDiagnosticSeverity severity, + /** Human-readable description of the finding. */ + @JsonProperty("message") String message +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnosticSeverity.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnosticSeverity.java new file mode 100644 index 0000000000..f5010d41b9 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsDiagnosticSeverity.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 javax.annotation.processing.Generated; + +/** + * Severity of a managed-settings validation finding. + * + * @apiNote This type is experimental and may change in a future version. + * + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum ManagedSettingsDiagnosticSeverity { + /** The {@code error} variant. */ + ERROR("error"), + /** The {@code warning} variant. */ + WARNING("warning"); + + private final String value; + ManagedSettingsDiagnosticSeverity(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static ManagedSettingsDiagnosticSeverity fromValue(String value) { + for (ManagedSettingsDiagnosticSeverity v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown ManagedSettingsDiagnosticSeverity value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsLayer.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsLayer.java new file mode 100644 index 0000000000..07b915e858 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsLayer.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * One managed-settings channel and the document it delivered. + * + * @apiNote This type 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 ManagedSettingsLayer( + /** Channel identifier: `device` (MDM, plist, registry, or managed file), `server` (account or organization policy), or `policyHelper` (session-local helper output, supported by compose). Treat unknown output values as additional channels; more may be added. */ + @JsonProperty("source") String source, + /** Validated managed-settings document this channel delivered. Absent when the channel delivered none. */ + @JsonProperty("settings") Object settings +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsMeta.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsMeta.java new file mode 100644 index 0000000000..7348141749 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsMeta.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * Per-key lock state and provenance for `ManagedSettingsValues`, with the same field names. Producers emit each typed key in values and meta together; both outer objects are omitted when no typed key is set. + * + * @apiNote This type 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 ManagedSettingsMeta( + /** Lock state and provenance of `values.model`. */ + @JsonProperty("model") ManagedSettingMeta model, + /** Lock state and provenance of `values.autoTier`. */ + @JsonProperty("autoTier") ManagedSettingMeta autoTier +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveParams.java new file mode 100644 index 0000000000..ffb5af2b18 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveParams.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; + +/** + * Request parameters for the {@code managedSettings.resolve} RPC method. + * + * @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 ManagedSettingsResolveParams( + /** Opaque account identifier returned by `account.getAllUsers`. When omitted, the current account is used, or device policy only when no account is signed in. */ + @JsonProperty("selectionId") String selectionId, + /** GitHub token to resolve instead of the current account. The call fails when the token cannot be resolved. */ + @JsonProperty("gitHubToken") String gitHubToken, + /** Embedding client identity for server policy requests, as in session creation. Omit for the CLI identity. */ + @JsonProperty("clientName") String clientName +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveResult.java new file mode 100644 index 0000000000..6d9941f4a9 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolveResult.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import javax.annotation.processing.Generated; + +/** + * Effective enterprise managed settings for an account, resolved without 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 ManagedSettingsResolveResult( + /** Printable opaque identity of the account the settings were resolved for, suitable for comparison and storage, not an account selectionId. Absent when no account was available, in which case only device policy is reported. */ + @JsonProperty("account") String account, + /** Effective managed settings from the device and account (server) channels, in the same shape as `session.managedSettings.get`, excluding session-local injection. */ + @JsonProperty("resolved") ManagedSettingsResolvedData resolved, + /** Typed effective values of managed settings, keyed like the managed-settings schema and already resolved across channels, with the `{ "overridable": ... }` wrapper removed. Present when policy sets at least one typed key. Keys not typed here are available in `resolved.settings`. */ + @JsonProperty("values") ManagedSettingsValues values, + /** Per-key lock state and provenance for the entries in `values`, using the same key names. */ + @JsonProperty("meta") ManagedSettingsMeta meta, + /** Each managed-settings channel consulted, strongest first, with the validated document it delivered before merging. `resolved.settings` is the merged result. More channels may be added over time. */ + @JsonProperty("layers") List layers, + /** Warnings about unavailable policy sources or a failed refresh served from cache. A cached response is not proof of a successful live fetch; `resolved.failClosed` separately describes enforcement. */ + @JsonProperty("diagnostics") List diagnostics +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolvedData.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolvedData.java new file mode 100644 index 0000000000..2020e9d434 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsResolvedData.java @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import javax.annotation.processing.Generated; + +/** + * Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. + * + * @apiNote This type 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 ManagedSettingsResolvedData( + /** 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, + /** Whether an actual device MDM/plist/registry/file managed-settings layer was present */ + @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. */ + @JsonProperty("sandboxEnabledByUndeterminedPolicy") Boolean sandboxEnabledByUndeterminedPolicy, + /** Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. */ + @JsonProperty("bypassPermissionsDisabled") Boolean bypassPermissionsDisabled, + /** Whether at least two managed sources supplied permission allowlists, so enforcement intersects them and the flattened settings payload omits `permissions.allow`. */ + @JsonProperty("permissionsAllowIntersected") Boolean permissionsAllowIntersected, + /** The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. */ + @JsonProperty("managedKeys") List managedKeys, + /** The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. */ + @JsonProperty("settings") Object settings +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsSchemaResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsSchemaResult.java new file mode 100644 index 0000000000..0975e8f4ff --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsSchemaResult.java @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * The authoring JSON schema for managed settings recognized by this runtime. + * + * @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 ManagedSettingsSchemaResult( + /** JSON schema (draft 2020-12) with descriptive shared `x-composition` annotations, not a complete runtime composition contract. Model, effortLevel, and contextTier remain coupled; use `managedSettings.compose` for the runtime's effective result. */ + @JsonProperty("schema") Object schema, + /** Version of the runtime that owns this schema. */ + @JsonProperty("runtimeVersion") String runtimeVersion +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateParams.java new file mode 100644 index 0000000000..32879f689c --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateParams.java @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * A candidate managed-settings document to validate without applying it. + * + * @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 ManagedSettingsValidateParams( + /** The document to validate: a JSON object, or a string containing the document's JSON text. Preview documents are limited to 1 MiB and 64 levels of nesting, a stricter resource limit than delivered-policy parsing; violations are returned as diagnostics. */ + @JsonProperty("content") Object content, + /** Channel the document is meant for (`device`, `server`, or `policyHelper`). Some keys are only honored in some channels; for example, a `policyHelper` registration is ignored in policy-helper output. When omitted, no channel-specific checks run. */ + @JsonProperty("layer") String layer +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateResult.java new file mode 100644 index 0000000000..ffc3da6340 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValidateResult.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import javax.annotation.processing.Generated; + +/** + * Result of validating a managed-settings document. + * + * @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 ManagedSettingsValidateResult( + /** Whether the runtime would accept the document within the preview resource limits. Always equals whether `settings` is present. An invalid document is rejected as a whole. */ + @JsonProperty("valid") Boolean valid, + /** Canonical form of the document the runtime would apply, with unrecognized keys removed. Absent when the document is invalid. */ + @JsonProperty("settings") Object settings, + /** Errors that reject the document and warnings about content the runtime ignores. */ + @JsonProperty("diagnostics") List diagnostics +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValues.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValues.java new file mode 100644 index 0000000000..931294b19c --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedSettingsValues.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * Typed effective values of managed settings. Each field mirrors the managed-settings schema key of the same name; more keys are added as they are typed. + * + * @apiNote This type 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 ManagedSettingsValues( + /** Managed default model identifier, as configured. New sessions start with it; it can name a model the account cannot use, so hosts match it against the listed models. */ + @JsonProperty("model") String model, + /** Managed Auto routing preference, used when the selected model is `auto`. */ + @JsonProperty("autoTier") AutoTier autoTier +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ServerManagedSettingsApi.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ServerManagedSettingsApi.java index e6013c870d..4f9cd612bf 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ServerManagedSettingsApi.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ServerManagedSettingsApi.java @@ -48,4 +48,61 @@ public CompletableFuture clearCache() { return caller.invoke("managedSettings.clearCache", java.util.Map.of(), Void.class); } + /** + * Optional opaque account selection or GitHub token whose managed settings are resolved. + *

+ * Invokes the method with no params, applying the runtime defaults. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture resolve() { + return resolve(null); + } + + /** + * Optional opaque account selection or GitHub token whose managed settings are resolved. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture resolve(ManagedSettingsResolveParams params) { + return caller.invoke("managedSettings.resolve", params == null ? java.util.Map.of() : params, ManagedSettingsResolveResult.class); + } + + /** + * The authoring JSON schema for managed settings recognized by this runtime. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture schema() { + return caller.invoke("managedSettings.schema", java.util.Map.of(), ManagedSettingsSchemaResult.class); + } + + /** + * A candidate managed-settings document to validate without applying it. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture validate(ManagedSettingsValidateParams params) { + return caller.invoke("managedSettings.validate", params, ManagedSettingsValidateResult.class); + } + + /** + * Candidate managed-settings documents to merge without applying them. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture compose(ManagedSettingsComposeParams params) { + return caller.invoke("managedSettings.compose", params, ManagedSettingsComposeResult.class); + } + } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionManagedSettingsGetResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionManagedSettingsGetResult.java index c432e26725..faf08c57d6 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionManagedSettingsGetResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionManagedSettingsGetResult.java @@ -15,7 +15,7 @@ import javax.annotation.processing.Generated; /** - * 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. + * Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 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 efe266de11..c65faff30e 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -640,10 +640,22 @@ private Connection startCoreBody() { startNanos); } // Clean up the spawned process if connection setup failed - if (process != null && cleanupCliProcess(process, true)) { - // Cleanup observed an exited child and left its streams open. - // Drain stderr before rpc.close() destroys those streams. - serverManager.awaitStderrReader(); + if (process != null) { + Throwable startupFailure = e; + while (startupFailure instanceof java.util.concurrent.ExecutionException + || startupFailure instanceof CompletionException) { + startupFailure = startupFailure.getCause(); + } + // A broken pipe can precede the child's own exit and trailing stderr. + // Other setup failures must terminate a live server before waiting for EOF. + boolean drainBeforeCleanup = startupFailure instanceof IOException; + if (drainBeforeCleanup) { + serverManager.awaitStderrReader(); + } + cleanupCliProcess(process, true); + if (!drainBeforeCleanup) { + serverManager.awaitStderrReader(); + } } if (rpc != null) { try { diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java index eac5d9763c..cd8359c09e 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java @@ -914,7 +914,8 @@ synchronized void event(SessionEvent event) { * The handler will be invoked for every event in this session, including * assistant messages, tool calls, and session state changes. For type-safe * handling of specific event types, prefer {@link #on(Class, Consumer)} - * instead. + * instead. A handler registered from an event handler does not receive the + * event currently being delivered. * *

* Exception handling: If a handler throws an exception, the error is diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java b/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java index 574f057467..9055cb19f7 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import com.github.copilot.generated.ExternalToolRequestedEvent; import com.github.copilot.rpc.CopilotClientOptions; @@ -20,6 +21,12 @@ import java.io.OutputStream; import java.lang.reflect.Field; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -30,6 +37,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -255,6 +263,111 @@ void testShouldReportErrorWithStderrWhenCliFailsToStart() throws Exception { } } + @Test + void testStartupWriteFailureDrainsStderrBeforeKillingChild(@TempDir Path tempDir) throws Exception { + var script = tempDir.resolve("startup-error.js"); + Files.writeString(script, """ + const fs = require('node:fs'); + fs.watch(__dirname, () => { + if (fs.existsSync(__dirname + '/release-stderr')) { + fs.writeSync(2, 'error: --nonexistent-flag-for-testing\\n'); + process.exit(1); + } + }); + fs.closeSync(0); + process.stdout.write('started\\n'); + """); + var options = new CopilotClientOptions().setCliPath(script.toString()).setUseStdio(true); + var manager = spy(new CliServerManager(options)); + var child = new AtomicReference(); + doAnswer(invocation -> { + Process process = invocation.getArgument(0); + child.set(process); + var reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + assertEquals("started", reader.readLine()); + return invocation.callRealMethod(); + }).when(manager).connectToServer(any(Process.class), isNull(), isNull()); + doAnswer(invocation -> { + // Release stderr only when startup drains it, while the child is still alive. + Files.createFile(tempDir.resolve("release-stderr")); + return invocation.callRealMethod(); + }).when(manager).awaitStderrReader(); + + try (var client = new CopilotClient(options)) { + Field managerField = CopilotClient.class.getDeclaredField("serverManager"); + managerField.setAccessible(true); + managerField.set(client, manager); + + var error = assertThrows(ExecutionException.class, () -> client.start().get(10, TimeUnit.SECONDS)); + var cause = error.getCause(); + assertTrue(cause.getMessage().contains("--nonexistent-flag-for-testing"), + "Startup lost the child's stderr: " + cause); + assertInstanceOf(IOException.class, cause); + Throwable original = cause; + while (original.getCause() != null) { + original = original.getCause(); + } + assertInstanceOf(IOException.class, original); + assertNotSame(cause, original, "The startup error must retain its original transport failure."); + assertFalse(child.get().isAlive(), "Failed startup retained its child process."); + } finally { + if (child.get() != null && child.get().isAlive()) { + child.get().destroyForcibly(); + assertTrue(child.get().waitFor(10, TimeUnit.SECONDS), "Could not reap the controlled child."); + } + } + } + + @Test + void testProtocolMismatchTerminatesLiveChildBeforeDrainingStderr(@TempDir Path tempDir) throws Exception { + var script = tempDir.resolve("incompatible-server.js"); + Files.writeString(script, """ + let input = Buffer.alloc(0); + process.stdin.on('data', chunk => { + input = Buffer.concat([input, chunk]); + const headerEnd = input.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length:\\s*(\\d+)/i.exec(input.toString())[1]); + if (input.length < headerEnd + 4 + length) return; + const request = JSON.parse(input.subarray(headerEnd + 4, headerEnd + 4 + length)); + const response = JSON.stringify({ + jsonrpc: '2.0', id: request.id, result: { protocolVersion: 1 } + }); + process.stdout.write(`Content-Length: ${Buffer.byteLength(response)}\\r\\n\\r\\n${response}`); + input = input.subarray(headerEnd + 4 + length); + }); + setInterval(() => {}, 1000); + """); + var options = new CopilotClientOptions().setCliPath(script.toString()).setUseStdio(true); + var manager = spy(new CliServerManager(options)); + var child = new AtomicReference(); + doAnswer(invocation -> { + child.set(invocation.getArgument(0)); + return invocation.callRealMethod(); + }).when(manager).connectToServer(any(Process.class), isNull(), isNull()); + doAnswer(invocation -> { + assertFalse(child.get().isAlive(), "A rejected live server must exit before its stderr reader is joined."); + return invocation.callRealMethod(); + }).when(manager).awaitStderrReader(); + + try (var client = new CopilotClient(options)) { + Field managerField = CopilotClient.class.getDeclaredField("serverManager"); + managerField.setAccessible(true); + managerField.set(client, manager); + + var error = assertThrows(ExecutionException.class, () -> client.start().get(10, TimeUnit.SECONDS)); + assertTrue(error.getCause().getMessage().contains("SDK protocol version mismatch"), + "Startup must report the incompatible protocol: " + error.getCause()); + assertFalse(child.get().isAlive(), "Failed startup retained its child process."); + verify(manager).awaitStderrReader(); + } finally { + if (child.get() != null && child.get().isAlive()) { + child.get().destroyForcibly(); + assertTrue(child.get().waitFor(10, TimeUnit.SECONDS), "Could not reap the incompatible server."); + } + } + } + @Test void testStartAndConnectUsingTcp() throws Exception { assertNotNull(cliPath, "Copilot CLI not found in PATH or COPILOT_CLI_PATH"); diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotSessionTest.java b/java/sdk/src/test/java/com/github/copilot/CopilotSessionTest.java index 5bf5be8820..94ccda7243 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotSessionTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotSessionTest.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -146,7 +147,7 @@ void testShouldReceiveSessionEvents() throws Exception { CopilotSession session = client .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); - List receivedEvents = new ArrayList<>(); + List receivedEvents = new CopyOnWriteArrayList<>(); CompletableFuture idleReceived = new CompletableFuture<>(); session.on(evt -> { diff --git a/java/sdk/src/test/java/com/github/copilot/ErrorHandlingTest.java b/java/sdk/src/test/java/com/github/copilot/ErrorHandlingTest.java index e091eea71a..e8283c2948 100644 --- a/java/sdk/src/test/java/com/github/copilot/ErrorHandlingTest.java +++ b/java/sdk/src/test/java/com/github/copilot/ErrorHandlingTest.java @@ -10,6 +10,7 @@ import java.util.logging.Logger; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterAll; @@ -61,7 +62,7 @@ void testHandlesToolCallingErrors_toolErrorDoesNotCrashSession() throws Exceptio LOG.info("Running test: testHandlesToolCallingErrors_toolErrorDoesNotCrashSession"); ctx.configureForTest("tools", "handles_tool_calling_errors"); - var allEvents = new ArrayList(); + var allEvents = new CopyOnWriteArrayList(); var idleReceived = new CompletableFuture(); ToolDefinition errorTool = ToolDefinition.create("get_user_location", "Gets the user's location", 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 30b04fd972..d8bc68906b 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionEventHandlingTest.java @@ -21,6 +21,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; @@ -85,6 +86,35 @@ void testGenericEventHandler() { assertInstanceOf(SessionIdleEvent.class, receivedEvents.get(2)); } + @Test + void testHandlerAddedDuringDispatchStartsWithNextEvent() { + var received = new ArrayList(); + // Fixed hashes make the old weakly consistent iterator visit the new handler + // after the registering handlers, without relying on scheduling or identity + // hashes. + record Handler(int hash, Consumer callback) implements Consumer { + @Override + public int hashCode() { + return hash; + } + + @Override + public void accept(SessionEvent event) { + callback.accept(event); + } + } + var added = new Handler(15, received::add); + session.on(new Handler(0, event -> session.on(added))); + session.on(new Handler(1, event -> session.on(added))); + + dispatchEvent(createSessionIdleEvent()); + assertTrue(received.isEmpty(), "A new wait must not receive the idle event already being dispatched"); + + var next = createAssistantMessageEvent("HELLO"); + dispatchEvent(next); + assertEquals(List.of(next), received); + } + @Test void testExternalToolCompletedCancelsBlockedHandler() throws Exception { var toolFuture = new CompletableFuture(); diff --git a/java/sdk/src/test/java/com/github/copilot/StreamingFidelityTest.java b/java/sdk/src/test/java/com/github/copilot/StreamingFidelityTest.java index 3701cf9c13..c506d23522 100644 --- a/java/sdk/src/test/java/com/github/copilot/StreamingFidelityTest.java +++ b/java/sdk/src/test/java/com/github/copilot/StreamingFidelityTest.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterAll; @@ -158,7 +159,7 @@ void testShouldProduceDeltasAfterSessionResume() throws Exception { CopilotSession session2 = newClient.resumeSession(sessionId, new ResumeSessionConfig() .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(true)).get(); - List events = new ArrayList<>(); + List events = new CopyOnWriteArrayList<>(); session2.on(events::add); AssistantMessageEvent answer = session2 diff --git a/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java index c2ad45ff24..009773bb54 100644 --- a/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SubagentHooksE2ETest.java @@ -5,8 +5,10 @@ package com.github.copilot; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.InputStream; import java.net.http.HttpRequest; @@ -14,12 +16,14 @@ import java.nio.file.Files; import java.util.HashMap; import java.util.List; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; @@ -39,28 +43,51 @@ void shouldInvokePreToolUseAndPostToolUseHooksForSubAgentToolCalls() throws Exce ConcurrentLinkedQueue hookLog = new ConcurrentLinkedQueue<>(); RecordingForwardingRequestHandler requestHandler = new RecordingForwardingRequestHandler(); + String waitingText = "I've launched an explore agent to read subagent-test.txt. Waiting for it to complete..."; + String finalText = "The explore agent successfully read the file. The contents of **subagent-test.txt** are:\n\n```\nHello from subagent test!\n```"; + String parentSessionId = UUID.randomUUID().toString(); + CompletableFuture parentWaiting = new CompletableFuture<>(); HashMap env = new HashMap<>(ctx.getEnvironment()); env.put("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true"); try (CopilotClient client = ctx .createClient(new CopilotClientOptions().setEnvironment(env).setRequestHandler(requestHandler))) { - CopilotSession session = client - .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) - .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { - hookLog.add(new HookEntry("pre", input.getToolName(), input.getSessionId())); - return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); - }).setOnPostToolUse((input, invocation) -> { - hookLog.add(new HookEntry("post", input.getToolName(), input.getSessionId())); - return CompletableFuture.completedFuture((PostToolUseHookOutput) null); - }))) - .get(); - try { + CopilotSession session = client.createSession(new SessionConfig().setSessionId(parentSessionId) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + hookLog.add(new HookEntry("pre", input.getToolName(), input.getSessionId())); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + }).setOnPostToolUse((input, invocation) -> { + hookLog.add(new HookEntry("post", input.getToolName(), input.getSessionId())); + // A fast child can inject its result before the fixture's waiting reply is + // requested. + if ("view".equals(input.getToolName()) && !parentSessionId.equals(input.getSessionId())) { + return parentWaiting.thenApply(ignored -> null); + } + return CompletableFuture.completedFuture((PostToolUseHookOutput) null); + }))).get(); + try (var subscription = session.on(AssistantMessageEvent.class, message -> { + if ((message.getAgentId() == null || message.getAgentId().isEmpty()) + && waitingText.equals(message.getData().content())) { + parentWaiting.complete(null); + } + })) { Files.writeString(ctx.getWorkDir().resolve("subagent-test.txt"), "Hello from subagent test!"); - session.sendAndWait(new MessageOptions() + var response = session.sendAndWait(new MessageOptions() .setPrompt("Use the task tool to spawn an explore agent that reads the file " + "subagent-test.txt in the current directory and reports its contents. " + "You must use the task tool.")) .get(120, TimeUnit.SECONDS); + assertNotNull(response); + assertTrue(response.getAgentId() == null || response.getAgentId().isEmpty()); + assertEquals(finalText, response.getData().content()); + var replies = session.getMessages().get().stream() + .filter(event -> event instanceof AssistantMessageEvent + && (event.getAgentId() == null || event.getAgentId().isEmpty())) + .map(event -> ((AssistantMessageEvent) event).getData().content()) + .filter(content -> waitingText.equals(content) || finalText.equals(content)).toList(); + assertEquals(List.of(waitingText, finalText), replies, + "Durable history must contain the waiting reply before the final reply"); HookEntry taskPre = hookLog.stream() .filter(h -> h.kind().equals("pre") && h.toolName().equals("task")).findFirst() @@ -77,6 +104,7 @@ void shouldInvokePreToolUseAndPostToolUseHooksForSubAgentToolCalls() throws Exce "Sub-agent tool hooks should have a different sessionId than parent tool hooks"); assertSubagentRequestMetadata(requestHandler.inferenceRequests()); } finally { + parentWaiting.complete(null); session.close(); } } diff --git a/nodejs/package.json b/nodejs/package.json index 562a2913a2..a6f5d21702 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/github/copilot-sdk.git" }, "version": "0.0.0-dev", - "copilotCliVersion": "1.0.90", + "copilotCliVersion": "1.0.91-0", "description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC", "main": "./dist/cjs/index.js", "types": "./dist/index.d.ts", diff --git a/nodejs/src/cliVersion.ts b/nodejs/src/cliVersion.ts index ccc4be8666..61f921ab58 100644 --- a/nodejs/src/cliVersion.ts +++ b/nodejs/src/cliVersion.ts @@ -1,3 +1,3 @@ -export const COPILOT_CLI_VERSION = "1.0.90"; +export const COPILOT_CLI_VERSION = "1.0.91-0"; export const COPILOT_CLI_USE_NPM_PACKAGE = false; diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 1abbefd0ac..d53e98d1f8 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -3481,13 +3481,23 @@ export class CopilotClient { this.githubTokenProviders.clear(); this.requestAdapter?.cancelPending(); connection.dispose(); + this.socket?.destroy(); + this.connection = null; + this.messageWriter = null; + this._rpc = null; + this._internalRpc = null; + this.modelsCache = null; }; - this.connection.onClose(() => { + const drainAndDisconnect = () => { if (messageWriter) messageWriter.suppressWriteErrors = true; // jsonrpc dispatches parsed messages asynchronously, one per event-loop // turn. Drain them before clearing callbacks and rejecting unanswered RPCs. void connection.drain().then(markDisconnected); - }); + }; + this.connection.onClose(drainAndDisconnect); + // Descendants can retain inherited output pipes after the runtime exits. + // Observe the owned process without waiting for those pipes to reach EOF. + this.cliProcess?.once("exit", drainAndDisconnect); this.connection.onError(() => { if (this.connection === connection) { this.state = "disconnected"; diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts index f2db8eb163..947417871a 100644 --- a/nodejs/src/ffiRuntimeHost.ts +++ b/nodejs/src/ffiRuntimeHost.ts @@ -243,7 +243,7 @@ export class FfiRuntimeHost { ); if (!this.connectionId) { this.unregisterCallback(); - this.lib.hostShutdown(this.serverId); + await this.shutdownHost(); this.serverId = 0; throw new Error("copilot_runtime_connection_open failed."); } @@ -256,7 +256,7 @@ export class FfiRuntimeHost { } finally { this.starting = false; if (this.disposed) { - void this.tryFinalizeCleanup(); + await this.tryFinalizeCleanup(); } } } @@ -305,6 +305,19 @@ export class FfiRuntimeHost { } } + private shutdownHost(): Promise { + // Native shutdown waits for probe reaping; keep the JS event loop free while it joins. + return new Promise((resolvePromise, rejectPromise) => { + this.lib.hostShutdown.async(this.serverId, (error: Error | null, result: boolean) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(result); + } + }); + }); + } + private unregisterCallback(): boolean { if (this.outboundCallback === undefined) { return true; @@ -387,7 +400,7 @@ export class FfiRuntimeHost { if (this.serverId) { try { - if (!this.lib.hostShutdown(this.serverId)) { + if (!(await this.shutdownHost())) { console.error( `In-process FFI host shutdown did not recognize server ${this.serverId}.` ); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index e7fbb5d514..a37a661cd5 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -2626,6 +2626,32 @@ export type SessionLogLevel = | "warning" /** Error message describing a failure. */ | "error"; +/** + * A channel accepted by managedSettings.compose. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsChannel". + */ +/** @experimental */ +export type ManagedSettingsChannel = + /** Device policy, the strongest channel. */ + | "device" + /** Account or organization policy. */ + | "server" + /** Session-local helper output, the weakest channel. */ + | "policyHelper"; +/** + * Severity of a managed-settings validation finding. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsDiagnosticSeverity". + */ +/** @experimental */ +export type ManagedSettingsDiagnosticSeverity = + /** The runtime rejects the document. */ + | "error" + /** The runtime accepts the document but ignores the flagged content. */ + | "warning"; /** * UI theme preference per SEP-1865 * @@ -13301,6 +13327,130 @@ export interface ManagedMcpServerConfig { */ headersRefreshTtlMs?: number; } +/** + * Lock state and provenance of one managed setting. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingMeta". + */ +/** @experimental */ +export interface ManagedSettingMeta { + /** + * Whether users and repositories may choose a different value. `false` means policy locks the value. + */ + overridable: boolean; + /** + * Channel that supplied this scalar value, matching a `layers[].source`: `device`, `server`, or `policyHelper`. These scalar defaults select one winning channel, not a mixed source. Treat unknown values as additional channels; more may be added. + */ + source: string; +} +/** + * One candidate channel; absent settings represents a channel that delivered no document. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsComposeLayer". + */ +/** @experimental */ +export interface ManagedSettingsComposeLayer { + source: ManagedSettingsChannel; + /** + * Candidate managed-settings document. Omit when the channel delivered none, as in resolve output. + */ + settings?: JsonValue; +} +/** + * Candidate managed-settings documents to merge without applying them. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsComposeRequest". + */ +/** @experimental */ +export interface ManagedSettingsComposeRequest { + /** + * One entry per channel. `source` must be `device`, `server`, or `policyHelper`, each at most once (checked at runtime); order does not matter, because channel precedence is fixed. To preview documents from resolve output, map recognized source strings to ManagedSettingsChannel and copy their settings; generated resolve and compose layer types are distinct. Omitted settings means this channel delivered no document. Supplied documents must be valid within the preview limits; warnings are returned in diagnostics. Compose does not reproduce source-failure state or retained enforcement floors from resolve. + */ + layers: ManagedSettingsComposeLayer[]; +} +/** + * The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsComposeResult". + */ +/** @experimental */ +export interface ManagedSettingsComposeResult { + resolved: ManagedSettingsResolvedData; + values?: ManagedSettingsValues; + meta?: ManagedSettingsMeta; + /** + * Only the supplied channels, strongest first, with canonical documents. Empty canonical documents are represented as absent settings, as in live resolution. + */ + layers: ManagedSettingsLayer[]; + /** + * Warnings about ignored content, with paths prefixed by the channel name. + */ + diagnostics: ManagedSettingsDiagnostic[]; +} +/** + * Typed effective values of managed settings. Each field mirrors the managed-settings schema key of the same name; more keys are added as they are typed. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsValues". + */ +/** @experimental */ +export interface ManagedSettingsValues { + /** + * Managed default model identifier, as configured. New sessions start with it; it can name a model the account cannot use, so hosts match it against the listed models. + */ + model?: string; + autoTier?: AutoTier; +} +/** + * Per-key lock state and provenance for `ManagedSettingsValues`, with the same field names. Producers emit each typed key in values and meta together; both outer objects are omitted when no typed key is set. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsMeta". + */ +/** @experimental */ +export interface ManagedSettingsMeta { + model?: ManagedSettingMeta; + autoTier?: ManagedSettingMeta; +} +/** + * One managed-settings channel and the document it delivered. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsLayer". + */ +/** @experimental */ +export interface ManagedSettingsLayer { + /** + * Channel identifier: `device` (MDM, plist, registry, or managed file), `server` (account or organization policy), or `policyHelper` (session-local helper output, supported by compose). Treat unknown output values as additional channels; more may be added. + */ + source: string; + /** + * Validated managed-settings document this channel delivered. Absent when the channel delivered none. + */ + settings?: JsonValue; +} +/** + * One validation finding for a managed-settings document. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsDiagnostic". + */ +/** @experimental */ +export interface ManagedSettingsDiagnostic { + /** + * Dot-separated path of the offending setting, such as `autoTier.overridable`. Empty for the document as a whole. + */ + path: string; + severity: ManagedSettingsDiagnosticSeverity; + /** + * Human-readable description of the finding. + */ + message: string; +} /** * Validated device-managed settings discovered before a session exists. * @@ -13318,6 +13468,101 @@ export interface ManagedSettingsReadResult { */ errorMessage?: string; } + +/** @experimental */ +export interface ManagedSettingsResolveRequest { + /** + * Opaque account identifier returned by `account.getAllUsers`. When omitted, the current account is used, or device policy only when no account is signed in. + */ + selectionId?: string; + /** + * GitHub token to resolve instead of the current account. The call fails when the token cannot be resolved. + */ + gitHubToken?: string; + /** + * Embedding client identity for server policy requests, as in session creation. Omit for the CLI identity. + */ + clientName?: string; +} +/** + * Effective enterprise managed settings for an account, resolved without a session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsResolveResult". + */ +/** @experimental */ +export interface ManagedSettingsResolveResult { + /** + * Printable opaque identity of the account the settings were resolved for, suitable for comparison and storage, not an account selectionId. Absent when no account was available, in which case only device policy is reported. + */ + account?: string; + resolved: ManagedSettingsResolvedData; + values?: ManagedSettingsValues; + meta?: ManagedSettingsMeta; + /** + * Each managed-settings channel consulted, strongest first, with the validated document it delivered before merging. `resolved.settings` is the merged result. More channels may be added over time. + */ + layers: ManagedSettingsLayer[]; + /** + * Warnings about unavailable policy sources or a failed refresh served from cache. A cached response is not proof of a successful live fetch; `resolved.failClosed` separately describes enforcement. + */ + diagnostics: ManagedSettingsDiagnostic[]; +} +/** + * The authoring JSON schema for managed settings recognized by this runtime. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsSchemaResult". + */ +/** @experimental */ +export interface ManagedSettingsSchemaResult { + /** + * JSON schema (draft 2020-12) with descriptive shared `x-composition` annotations, not a complete runtime composition contract. Model, effortLevel, and contextTier remain coupled; use `managedSettings.compose` for the runtime's effective result. + */ + schema: JsonValue; + /** + * Version of the runtime that owns this schema. + */ + runtimeVersion: string; +} +/** + * A candidate managed-settings document to validate without applying it. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsValidateRequest". + */ +/** @experimental */ +export interface ManagedSettingsValidateRequest { + /** + * The document to validate: a JSON object, or a string containing the document's JSON text. Preview documents are limited to 1 MiB and 64 levels of nesting, a stricter resource limit than delivered-policy parsing; violations are returned as diagnostics. + */ + content: JsonValue; + /** + * Channel the document is meant for (`device`, `server`, or `policyHelper`). Some keys are only honored in some channels; for example, a `policyHelper` registration is ignored in policy-helper output. When omitted, no channel-specific checks run. + */ + layer?: string; +} +/** + * Result of validating a managed-settings document. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedSettingsValidateResult". + */ +/** @experimental */ +export interface ManagedSettingsValidateResult { + /** + * Whether the runtime would accept the document within the preview resource limits. Always equals whether `settings` is present. An invalid document is rejected as a whole. + */ + valid: boolean; + /** + * Canonical form of the document the runtime would apply, with unrecognized keys removed. Absent when the document is invalid. + */ + settings?: JsonValue; + /** + * Errors that reject the document and warnings about content the runtime ignores. + */ + diagnostics: ManagedSettingsDiagnostic[]; +} /** * Result of registering a new marketplace. * @@ -30020,17 +30265,51 @@ export function createServerRpc(connection: MessageConnection) { /** @experimental */ managedSettings: { /** - * Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session. + * Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session. `managedSettings.resolve` returns the same device settings together with the account's server policy. * * @returns Validated device-managed settings discovered before a session exists. */ read: async (): Promise => connection.sendRequest("managedSettings.read", {}), /** - * Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing "sync account policy" should start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed. + * Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing "sync account policy" should call `managedSettings.resolve` or start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed. */ clearCache: async (): Promise => connection.sendRequest("managedSettings.clearCache", {}), + /** + * Resolves the effective enterprise managed settings without a session, from the device channel and, when an account is available, the account's server policy through the same per-account cache sessions use. A cached server policy less than an hour old is used without a fetch; otherwise the policy is fetched, and when the fetch fails a cached policy up to 24 hours old is used instead, unless `forceRemoteSettingsRefresh` requires a live fetch. With no account requested or signed in, it reports device policy only; signing out removes the account's cached policy. It can fetch server policy over the network when the cache is stale, so call it off latency-critical paths such as startup rather than before listing models. The policy helper is not run. `layers` lists each channel's document before merging, and `values` and `meta` carry typed effective values and their lock state for the keys typed so far. + * + * @param params Optional opaque account selection or GitHub token whose managed settings are resolved. + * + * @returns Effective enterprise managed settings for an account, resolved without a session. + */ + resolve: async (params: ManagedSettingsResolveRequest): Promise => + connection.sendRequest("managedSettings.resolve", params), + /** + * Returns the managed-settings authoring JSON schema with descriptive `x-composition` annotations aligned with the shared settings-engine vocabulary. These annotations are not a complete runtime composition contract: model, effortLevel, and contextTier remain coupled. Use `managedSettings.compose` for the runtime's effective result. Performs no I/O. + * + * @returns The authoring JSON schema for managed settings recognized by this runtime. + */ + schema: async (): Promise => + connection.sendRequest("managedSettings.schema", {}), + /** + * Validates a candidate managed-settings document the way the runtime validates delivered policy, without applying it. Reports errors that would reject the document, warnings for content the runtime ignores, and the canonical document it would apply. Document text nested more than 64 levels deep is rejected. Performs no I/O. + * + * @param params A candidate managed-settings document to validate without applying it. + * + * @returns Result of validating a managed-settings document. + */ + validate: async (params: ManagedSettingsValidateRequest): Promise => + connection.sendRequest("managedSettings.validate", params), + /** + * Merges candidate managed-settings documents for the device, server, and policy-helper channels into the effective settings the runtime would enforce on this host, using the same precedence and composition rules as live resolution, without applying them. Like live resolution, a server's advisory sandbox force-enable is declined on a host that cannot run the sandbox. Does not fetch policy or read policy files, but may perform blocking OS or subprocess probes for sandbox support. Preview documents are limited to 1 MiB and 64 levels of nesting. + * + * @param params Candidate managed-settings documents to merge without applying them. + * + * @returns The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns. + */ + compose: async (params: ManagedSettingsComposeRequest): Promise => + connection.sendRequest("managedSettings.compose", params), }, /** @experimental */ runtime: { @@ -31745,7 +32024,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin /** * Waits for the live session's in-flight managed-settings application, then returns the retained effective snapshot used by runtime enforcement and by `session.managed_settings_resolved`. It does not perform another account, device, or server resolution, and rejects when resolution has not produced a snapshot. * - * @returns 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. + * @returns Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. */ get: async (): Promise => connection.sendRequest("session.managedSettings.get", { sessionId }), diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 96f6f247c5..452256920c 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -11807,7 +11807,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, 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. + * Session event "session.managed_settings_resolved". Effective enterprise managed settings applied to the session and their contributing channels. Emitted whenever managed policy is applied or reapplied, including session start, resume, and account switch. This ephemeral live snapshot is delivered to subscribers but not persisted to the session event log; initial resolution occurs before session.start. */ /** @experimental */ export interface ManagedSettingsResolvedEvent { @@ -11838,7 +11838,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, 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. + * Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. */ /** @experimental */ export interface ManagedSettingsResolvedData { diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 174f911287..b9360dc60f 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,10 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { EventEmitter } from "node:events"; +import { EventEmitter, once } from "node:events"; import { PassThrough } from "stream"; -import { mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, + type MessageConnection, +} from "vscode-jsonrpc/node.js"; import { approveAll, createAttributedPermissionResult, @@ -20,6 +28,7 @@ import { } from "../src/index.js"; import { CopilotSession } from "../src/session.js"; import { defaultJoinSessionPermissionHandler } from "../src/types.js"; +import { getSdkProtocolVersion } from "../src/sdkProtocolVersion.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead @@ -193,7 +202,7 @@ describe("CopilotClient", () => { const stdin = new PassThrough(); const stdout = new PassThrough(); - (client as any).cliProcess = { stdin, stdout }; + (client as any).cliProcess = Object.assign(new EventEmitter(), { stdin, stdout }); await (client as any).connectToChildProcessViaStdio(); const dispose = vi.spyOn((client as any).connection, "dispose"); @@ -3457,6 +3466,185 @@ describe("CopilotClient", () => { }); describe("unexpected disconnection", () => { + it("stops after the runtime exits even when its TCP peer stays open", async () => { + const directory = mkdtempSync(join(tmpdir(), "copilot-surviving-tcp-peer-")); + const runtimePath = join(directory, "runtime.js"); + const client = new CopilotClient({ + connection: RuntimeConnection.forTcp({ path: runtimePath }), + }); + let peer: Socket | undefined; + let serverConnection: MessageConnection | undefined; + // Retain the peer after FIN, independently of the owned runtime process. + const server = createServer({ allowHalfOpen: true }, (socket) => { + peer = socket; + serverConnection = createMessageConnection( + new StreamMessageReader(socket), + new StreamMessageWriter(socket) + ); + serverConnection.onRequest("connect", () => ({ + protocolVersion: getSdkProtocolVersion(), + })); + serverConnection.onRequest("session.create", (params) => ({ + sessionId: params.sessionId, + })); + serverConnection.listen(); + }); + onTestFinished(async () => { + peer?.destroy(); + serverConnection?.dispose(); + await client.forceStop(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + rmSync(directory, { recursive: true, force: true }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a TCP listening address"); + } + writeFileSync( + runtimePath, + `console.log("listening on port ${address.port}"); setInterval(() => {}, 1000);` + ); + await client.createSession({ onPermissionRequest: approveAll }); + const socket = client["socket"]!; + const child = client["cliProcess"]!; + const closed = once(socket, "close", { signal: AbortSignal.timeout(10_000) }); + const exited = once(child, "exit"); + expect(child.kill()).toBe(true); + await exited; + await closed; + expect(client["state"]).toBe("disconnected"); + expect(await client.stop()).toEqual([]); + expect(peer?.destroyed).toBe(false); + }); + + it("disconnects when the runtime exits before an inherited output pipe closes", async () => { + const directory = mkdtempSync(join(tmpdir(), "copilot-inherited-output-")); + const runtimePath = join(directory, "runtime.js"); + const holderPidPath = join(directory, "holder.pid"); + const jsonrpcPath = createRequire(import.meta.url).resolve("vscode-jsonrpc/node"); + writeFileSync( + runtimePath, + ` +const { spawn } = require("node:child_process"); +const { once } = require("node:events"); +const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = require(${JSON.stringify(jsonrpcPath)}); +const holder = spawn(process.execPath, ["-e", "process.send('ready'); setInterval(() => {}, 1000);"], { + stdio: ["ignore", process.stdout, process.stderr, "ipc"], + // Survive the runtime's Windows kill-on-close job while retaining its pipes. + detached: true, + windowsHide: true, +}); +require("node:fs").writeFileSync(${JSON.stringify(holderPidPath)}, String(holder.pid)); +const ready = once(holder, "message"); +const connection = createMessageConnection(new StreamMessageReader(process.stdin), new StreamMessageWriter(process.stdout)); +connection.onRequest("connect", async () => { + await ready; + return { protocolVersion: ${getSdkProtocolVersion()} }; +}); +connection.onRequest("session.create", (params) => ({ sessionId: params.sessionId })); +connection.onRequest("ping", async () => { + await connection.sendNotification("test.pingReceived"); + return new Promise(() => {}); +}); +connection.listen(); +` + ); + const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ path: runtimePath }), + }); + let closedPromise: Promise | undefined; + const stopHolder = () => { + if (existsSync(holderPidPath)) { + try { + process.kill(Number(readFileSync(holderPidPath, "utf8"))); + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "ESRCH") + ) { + throw error; + } + } + } + }; + onTestFinished(async () => { + stopHolder(); + await client.forceStop(); + await closedPromise; + rmSync(directory, { recursive: true, force: true }); + }); + await client.start(); + + let signal: AbortSignal | undefined; + let started!: () => void; + const toolStarted = new Promise((resolve) => { + started = resolve; + }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + { + name: "blocked_tool", + description: "blocks until cancelled", + handler: async (_args, invocation) => { + signal = invocation.signal; + started(); + await new Promise((resolve) => + invocation.signal?.addEventListener("abort", () => resolve(), { + once: true, + }) + ); + return "cancelled"; + }, + }, + ], + }); + (session as any)._handleBroadcastEvent({ + type: "external_tool.requested", + data: { + requestId: "inherited-output-request", + sessionId: session.sessionId, + toolCallId: "inherited-output-tool", + toolName: "blocked_tool", + arguments: {}, + }, + }); + await toolStarted; + const pingReceived = new Promise((resolve) => + client["connection"]!.onNotification("test.pingReceived", resolve) + ); + let pingError: unknown; + const ping = client.ping().catch((error) => { + pingError = error; + }); + await pingReceived; + const child = client["cliProcess"]!; + const exited = once(child, "exit"); + closedPromise = once(child, "close"); + let closed = false; + child.once("close", () => { + closed = true; + }); + child.kill(); + await exited; + expect(() => + process.kill(Number(readFileSync(holderPidPath, "utf8")), 0) + ).not.toThrow(); + await vi.waitFor(() => + expect(pingError).toMatchObject({ + message: "Pending response rejected since connection got disposed", + }) + ); + await ping; + expect(closed).toBe(false); + expect(client["state"]).toBe("disconnected"); + expect(signal?.aborted).toBe(true); + stopHolder(); + await closedPromise; + }); + // No child process exists over the in-process (FFI) transport, so this // child-process-kill scenario does not apply there. Covered by the default // (stdio) cell. @@ -3466,6 +3654,12 @@ describe("CopilotClient", () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => stopClient(client)); + vi.spyOn(client["connection"]!, "sendRequest").mockResolvedValueOnce({ + models: [{ id: "before-restart" }], + }); + expect((await client.listModels()).map((model) => model.id)).toEqual([ + "before-restart", + ]); let invocationSignal: AbortSignal | undefined; let toolStarted!: () => void; const started = new Promise((resolve) => { @@ -3515,6 +3709,20 @@ describe("CopilotClient", () => { expect((client as any).state).toBe("disconnected"); expect(invocationSignal?.aborted).toBe(true); }); + await expect(client.listModels()).rejects.toThrow("Client not connected"); + + await client.start(); + expect(client["state"]).toBe("connected"); + await expect(client.ping("restarted")).resolves.toMatchObject({ + message: "pong: restarted", + }); + const sendRequest = vi + .spyOn(client["connection"]!, "sendRequest") + .mockResolvedValueOnce({ models: [{ id: "after-restart" }] }); + expect((await client.listModels()).map((model) => model.id)).toEqual([ + "after-restart", + ]); + expect(sendRequest).toHaveBeenCalledWith("models.list", {}); } ); }); diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index a529ea8e4c..a868c076d0 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -1,7 +1,7 @@ import { ChildProcess } from "child_process"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; -import { isInProcessTransport } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; function onTestFinishedStop(client: CopilotClient) { onTestFinished(async () => { @@ -157,31 +157,25 @@ describe("Client", () => { await client.stop(); }); - it("should list models when authenticated", async () => { - const client = new CopilotClient(); - onTestFinishedStop(client); + describe("model catalog", async () => { + const { copilotClient: client } = await createSdkTestContext(); - await client.start(); + it("should list models when authenticated", async () => { + await client.start(); - const authStatus = await client.getAuthStatus(); - if (!authStatus.isAuthenticated) { - // Skip if not authenticated - models.list requires auth - await client.stop(); - return; - } + const authStatus = await client.getAuthStatus(); + expect(authStatus.isAuthenticated).toBe(true); - const models = await client.listModels(); - expect(Array.isArray(models)).toBe(true); - if (models.length > 0) { + const models = await client.listModels(); + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); const model = models[0]; expect(model.id).toBeDefined(); expect(model.name).toBeDefined(); expect(model.capabilities).toBeDefined(); expect(model.capabilities.supports).toBeDefined(); expect(model.capabilities.limits).toBeDefined(); - } - - await client.stop(); + }); }); it.skipIf(isInProcessTransport)("should report error when CLI fails to start", async () => { diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index c92760676d..90afa68399 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -108,6 +108,62 @@ describe("Server-scoped RPC", async () => { await expect(client.rpc.managedSettings.clearCache()).resolves.toBeNull(); }); + it.skipIf(isInProcessTransport)("should round trip sessionless managed settings", async () => { + const policyPath = path.join(workDir, "managed-settings.json"); + const policy = { model: "gpt-5.4", autoTier: "balance" }; + fs.writeFileSync(policyPath, JSON.stringify(policy)); + const policyClient = new CopilotClient({ + workingDirectory: workDir, + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + useLoggedInUser: false, + env: { + ...env, + GH_TOKEN: "", + GITHUB_TOKEN: "", + COPILOT_GITHUB_TOKEN: "", + GITHUB_COPILOT_API_TOKEN: "", + COPILOT_HMAC_KEY: "", + CAPI_HMAC_KEY: "", + COPILOT_E2E_TEST_HOOKS: "1", + COPILOT_TEST_MANAGED_SETTINGS_FILE_PATH: policyPath, + }, + }); + onTestFinished(() => policyClient.stop()); + await policyClient.start(); + + const api = policyClient.rpc.managedSettings; + const schema = await api.schema(); + expect(schema.runtimeVersion).toEqual(expect.any(String)); + expect(schema.schema).toHaveProperty("properties.model"); + + const validated = await api.validate({ content: policy }); + expect(validated.valid).toBe(true); + expect(validated.settings).toEqual(policy); + expect(validated.diagnostics).toEqual([]); + + const resolved = await api.resolve({}); + expect(resolved.account).toBeUndefined(); + expect(resolved.resolved.deviceManaged).toBe(true); + expect(resolved.values?.model).toBe(policy.model); + const device = resolved.layers.find((layer) => layer.source === "device"); + expect(device?.settings).toMatchObject(policy); + + const composed = await api.compose({ + layers: [{ source: "device", settings: device?.settings }], + }); + expect(composed.resolved.settings).toEqual(resolved.resolved.settings); + expect(composed.values).toEqual(resolved.values); + expect(composed.diagnostics).toEqual([]); + await expect( + api.compose({ + // @ts-expect-error Exercise invalid wire input through the real server. + layers: [{ source: "device", settings: null }], + }) + ).rejects.toMatchObject({ code: -32602 }); + const absent = await api.compose({ layers: [{ source: "device" }] }); + expect(absent.resolved.source).toBe("none"); + }); + it("should reject llm inference response frames for missing request", async () => { await client.start(); diff --git a/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts b/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts index 7ea5721ef6..f888a1a453 100644 --- a/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts +++ b/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts @@ -313,7 +313,7 @@ describe("Generated RPC surface coverage", () => { ...collectRuntimeFunctions(session.rpc, "session"), ]); - expect(inventory).toHaveLength(376); + expect(inventory).toHaveLength(380); const boundInstallationMethods = [ "mcp.prepareInstall", "mcp.applyInstall", diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts index dbc3ca673b..767bc4b656 100644 --- a/nodejs/test/e2e/subagent_hooks.e2e.test.ts +++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts @@ -3,6 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { writeFile } from "fs/promises"; +import { randomUUID } from "node:crypto"; import { join } from "path"; import { describe, expect, it } from "vitest"; import type { @@ -85,8 +86,18 @@ describe("Subagent hooks", async () => { it("should invoke preToolUse and postToolUse hooks for sub-agent tool calls", async () => { const hookLog: { kind: "pre" | "post"; toolName: string; sessionId: string }[] = []; + const waitingText = + "I've launched an explore agent to read subagent-test.txt. Waiting for it to complete..."; + const finalText = + "The explore agent successfully read the file. The contents of **subagent-test.txt** are:\n\n```\nHello from subagent test!\n```"; + const parentSessionId = randomUUID(); + let releaseView!: () => void; + const parentWaiting = new Promise((resolve) => { + releaseView = resolve; + }); const session = await client.createSession({ + sessionId: parentSessionId, onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input: PreToolUseHookInput) => { @@ -103,6 +114,9 @@ describe("Subagent hooks", async () => { toolName: input.toolName, sessionId: input.sessionId, }); + // A fast child can inject its result before the parent ever asks for the fixture's waiting reply. + if (input.toolName === "view" && input.sessionId !== parentSessionId) + await parentWaiting; return null as PostToolUseHookOutput; }, }, @@ -111,9 +125,31 @@ describe("Subagent hooks", async () => { // Create a file for the sub-agent to read await writeFile(join(workDir, "subagent-test.txt"), "Hello from subagent test!"); - await session.sendAndWait({ - prompt: "Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and reports its contents. You must use the task tool.", + const unsubscribe = session.on((event) => { + if ( + !event.agentId && + event.type === "assistant.message" && + event.data.content === waitingText + ) { + releaseView(); + } }); + try { + const response = await session.sendAndWait({ + prompt: "Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and reports its contents. You must use the task tool.", + }); + expect(response?.agentId ?? "").toBe(""); + expect(response?.data.content).toBe(finalText); + expect( + (await session.getEvents()) + .filter((event) => !event.agentId && event.type === "assistant.message") + .map((event) => event.data.content) + .filter((content) => content === waitingText || content === finalText) + ).toEqual([waitingText, finalText]); + } finally { + releaseView(); + unsubscribe(); + } // Parent tool hooks fire for "task" const taskPre = hookLog.find((h) => h.kind === "pre" && h.toolName === "task"); diff --git a/nodejs/test/ffiRuntimeHost.test.ts b/nodejs/test/ffiRuntimeHost.test.ts index e0fb3d71af..b5badfb27c 100644 --- a/nodejs/test/ffiRuntimeHost.test.ts +++ b/nodejs/test/ffiRuntimeHost.test.ts @@ -1,3 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + /* eslint-disable @typescript-eslint/no-explicit-any */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -17,7 +21,14 @@ const ffi = vi.hoisted(() => { ) => callback(null, 11) ), }); - const hostShutdown = vi.fn(() => true); + const hostShutdown = Object.assign( + vi.fn(() => true), + { + async: vi.fn< + (serverId: number, callback: (error: Error | null, result: boolean) => void) => void + >(), + } + ); const connectionOpen = vi.fn(() => 21); const connectionWrite = vi.fn(() => true); const connectionClose = Object.assign(vi.fn<() => boolean>(), { @@ -82,6 +93,9 @@ describe("FfiRuntimeHost callback cleanup", () => { .mockImplementation((_id, callback) => callback(null, true)); ffi.connectionOpen.mockClear(); ffi.hostShutdown.mockClear(); + ffi.hostShutdown.async + .mockReset() + .mockImplementation((_id, callback) => callback(null, true)); ffi.hostStart.mockClear(); ffi.hostStart.async.mockClear(); ffi.register.mockClear(); @@ -90,6 +104,7 @@ describe("FfiRuntimeHost callback cleanup", () => { afterEach(() => { expect(ffi.connectionClose).not.toHaveBeenCalled(); + expect(ffi.hostShutdown).not.toHaveBeenCalled(); vi.clearAllTimers(); vi.useRealTimers(); }); @@ -103,7 +118,7 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); expect(ffi.unregister).not.toHaveBeenCalled(); - expect(ffi.hostShutdown).not.toHaveBeenCalled(); + expect(ffi.hostShutdown.async).not.toHaveBeenCalled(); expect((host as any).outboundCallback).toBe(ffi.callbackToken); expect((host as any).keepAliveTimer).toBeDefined(); @@ -111,7 +126,7 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(ffi.connectionClose.async).toHaveBeenCalledTimes(2); expect(ffi.unregister).toHaveBeenCalledTimes(1); - expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); expect((host as any).outboundCallback).toBeUndefined(); expect((host as any).keepAliveTimer).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); @@ -120,7 +135,7 @@ describe("FfiRuntimeHost callback cleanup", () => { await vi.advanceTimersByTimeAsync(100); expect(ffi.connectionClose.async).toHaveBeenCalledTimes(2); expect(ffi.unregister).toHaveBeenCalledTimes(1); - expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); }); it("waits for successful initial cleanup without overlapping close calls", async () => { @@ -141,7 +156,7 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(disposed).toBe(false); expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); expect(ffi.unregister).not.toHaveBeenCalled(); - expect(ffi.hostShutdown).not.toHaveBeenCalled(); + expect(ffi.hostShutdown.async).not.toHaveBeenCalled(); expect((host as any).outboundCallback).toBe(ffi.callbackToken); expect((host as any).keepAliveTimer).toBeDefined(); @@ -150,7 +165,7 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(disposed).toBe(true); expect(ffi.unregister).toHaveBeenCalledTimes(1); - expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); @@ -172,7 +187,7 @@ describe("FfiRuntimeHost callback cleanup", () => { await vi.advanceTimersByTimeAsync(100); expect(ffi.unregister).toHaveBeenCalledTimes(1); - expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); @@ -195,7 +210,7 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); expect(ffi.unregister).not.toHaveBeenCalled(); - expect(ffi.hostShutdown).not.toHaveBeenCalled(); + expect(ffi.hostShutdown.async).not.toHaveBeenCalled(); expect((host as any).outboundCallback).toBe(ffi.callbackToken); expect((FfiRuntimeHost as any).quarantinedHosts.has(host)).toBe(true); expect(error).toHaveBeenCalledTimes(1); @@ -205,7 +220,7 @@ describe("FfiRuntimeHost callback cleanup", () => { ); it("does not retry a terminal host shutdown failure", async () => { - ffi.hostShutdown.mockReturnValueOnce(false); + ffi.hostShutdown.async.mockImplementationOnce((_id, callback) => callback(null, false)); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); await host.start(); @@ -215,7 +230,7 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); expect(ffi.unregister).toHaveBeenCalledTimes(1); - expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); error.mockRestore(); }); @@ -233,7 +248,7 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); expect(ffi.unregister).toHaveBeenCalledTimes(1); - expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); expect((host as any).outboundCallback).toBe(ffi.callbackToken); expect((FfiRuntimeHost as any).quarantinedHosts.has(host)).toBe(true); expect((host as any).keepAliveTimer).toBeUndefined(); @@ -243,4 +258,149 @@ describe("FfiRuntimeHost callback cleanup", () => { expect(ffi.unregister).toHaveBeenCalledTimes(1); error.mockRestore(); }); + + it("joins host shutdown before startup rejects after disposal during startup", async () => { + let finishStart!: (error: Error | null, result: number) => void; + let finishShutdown: ((error: Error | null, result: boolean) => void) | undefined; + ffi.hostStart.async.mockImplementationOnce( + (_argv, _argvLength, _env, _envLength, callback) => { + finishStart = callback; + } + ); + ffi.hostShutdown.async.mockImplementationOnce((_id, callback) => { + finishShutdown = callback; + }); + const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); + const settled = vi.fn(); + const startup = host.start().catch((error: unknown) => { + settled(); + return error; + }); + await host.dispose(); + finishStart(null, 11); + try { + await vi.advanceTimersByTimeAsync(0); + expect(ffi.hostShutdown.async).toHaveBeenCalledExactlyOnceWith( + 11, + expect.any(Function) + ); + expect(settled).not.toHaveBeenCalled(); + } finally { + finishShutdown?.(null, true); + await startup; + } + expect(await startup).toEqual( + new Error("The in-process runtime host was disposed during startup.") + ); + expect(host["serverId"]).toBe(0); + expect(ffi.connectionOpen).not.toHaveBeenCalled(); + }); + + it.each(["disposal", "connection-open failure"])( + "awaits asynchronous host shutdown while JS progresses during %s", + async (path) => { + let finishShutdown: ((error: Error | null, result: boolean) => void) | undefined; + ffi.hostShutdown.async.mockImplementationOnce((_id, callback) => { + finishShutdown = callback; + }); + const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); + if (path === "connection-open failure") { + ffi.connectionOpen.mockReturnValueOnce(0); + } else { + await host.start(); + } + + const settled = vi.fn(); + const operation = (path === "disposal" ? host.dispose() : host.start()).then( + () => { + settled(); + return undefined; + }, + (error: unknown) => { + settled(); + return error; + } + ); + try { + const progress = vi.fn(); + setImmediate(progress); + await vi.advanceTimersByTimeAsync(0); + + expect(ffi.hostShutdown.async).toHaveBeenCalledExactlyOnceWith( + 11, + expect.any(Function) + ); + expect(progress).toHaveBeenCalledTimes(1); + expect(settled).not.toHaveBeenCalled(); + expect(host["serverId"]).toBe(11); + // Callback reclamation precedes host shutdown only once no live connection remains. + expect(ffi.connectionClose.async).toHaveBeenCalledTimes( + path === "disposal" ? 1 : 0 + ); + expect(ffi.unregister).toHaveBeenCalledExactlyOnceWith(ffi.callbackToken); + expect(host["outboundCallback"]).toBeUndefined(); + + await host.dispose(); + await vi.advanceTimersByTimeAsync(500); + + expect(settled).not.toHaveBeenCalled(); + expect(host["serverId"]).toBe(11); + expect(host["starting"]).toBe(path === "connection-open failure"); + expect(host["cleanupInProgress"]).toBe(path === "disposal"); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); + expect(ffi.unregister).toHaveBeenCalledTimes(1); + } finally { + finishShutdown?.(null, true); + await operation; + await host.dispose(); + } + + if (path === "connection-open failure") { + expect(await operation).toEqual( + new Error("copilot_runtime_connection_open failed.") + ); + } else { + expect(await operation).toBeUndefined(); + } + expect(host["serverId"]).toBe(0); + expect(host["starting"]).toBe(false); + expect(host["cleanupInProgress"]).toBe(false); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); + expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } + ); + + it.each(["callback", "throw"])( + "preserves host shutdown %s errors during disposal", + async (failure) => { + const shutdownError = new Error("shutdown failed"); + ffi.hostShutdown.async.mockImplementationOnce((_id, callback) => { + if (failure === "throw") { + throw shutdownError; + } + callback(shutdownError, false); + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); + try { + await host.start(); + await host.dispose(); + await vi.advanceTimersByTimeAsync(500); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining( + "Failed to shut down in-process FFI host: Error: shutdown failed" + ) + ); + expect(ffi.hostShutdown.async).toHaveBeenCalledTimes(1); + expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(host["serverId"]).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + await host.dispose(); + error.mockRestore(); + } + } + ); }); diff --git a/python/copilot/_ffi_runtime_host.py b/python/copilot/_ffi_runtime_host.py index 8674ed6ebc..a9c77224f8 100644 --- a/python/copilot/_ffi_runtime_host.py +++ b/python/copilot/_ffi_runtime_host.py @@ -493,7 +493,8 @@ def dispose(self) -> None: """Close the FFI connection, shut down the native host, release resources. Idempotent. Callback state remains rooted until connection_close reports - that native callbacks are quiescent. + that native callbacks are quiescent. Native shutdown may block; async + callers must run this method off the event loop. """ with self._dispose_lock: if self._disposed: diff --git a/python/copilot/client.py b/python/copilot/client.py index 27ce92ba92..d9c015c517 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2183,7 +2183,7 @@ async def stop(self) -> None: # Dispose the in-process FFI host and release the loaded native library. if self._ffi_host is not None: try: - self._ffi_host.dispose() + await asyncio.to_thread(self._ffi_host.dispose) except Exception: logger.debug("Error while disposing in-process FFI host", exc_info=True) self._ffi_host = None @@ -2268,7 +2268,8 @@ async def force_stop(self) -> None: # Close the transport first to signal the server immediately. # For external servers (TCP), this closes the socket. # For spawned processes (stdio), this kills the process. - if self._process is not None or self._cli_process is not None: + # The FFI adapter is disposed off-thread below. + if self._ffi_host is None and (self._process is not None or self._cli_process is not None): try: if self._is_external_server: if self._process is not None: @@ -2288,7 +2289,7 @@ async def force_stop(self) -> None: # Force-dispose the in-process FFI host before tearing down JSON-RPC. if self._ffi_host is not None: try: - self._ffi_host.dispose() + await asyncio.to_thread(self._ffi_host.dispose) except Exception: logger.debug("Error while force-disposing in-process FFI host", exc_info=True) self._ffi_host = None diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 5cb54dca15..c5aa03d313 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -5590,45 +5590,98 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class ManagedSettingsReadResult: - """Validated device-managed settings discovered before a session exists.""" +class ManagedSettingMeta: + """Lock state and provenance of one managed setting. - error_message: str | None = None - """Discovery or validation error text when managed settings could not be read safely.""" + Lock state and provenance of `values.autoTier`. - settings_json: Any = None - """Validated, canonical managed-settings JSON. Omitted when no managed settings were - discovered or when discovered settings failed validation. + Lock state and provenance of `values.model`. + """ + overridable: bool + """Whether users and repositories may choose a different value. `false` means policy locks + the value. + """ + source: str + """Channel that supplied this scalar value, matching a `layers[].source`: `device`, + `server`, or `policyHelper`. These scalar defaults select one winning channel, not a + mixed source. Treat unknown values as additional channels; more may be added. """ @staticmethod - def from_dict(obj: Any) -> 'ManagedSettingsReadResult': + def from_dict(obj: Any) -> 'ManagedSettingMeta': assert isinstance(obj, dict) - error_message = from_union([from_str, from_none], obj.get("errorMessage")) - settings_json = obj.get("settingsJson") - return ManagedSettingsReadResult(error_message, settings_json) + overridable = from_bool(obj.get("overridable")) + source = from_str(obj.get("source")) + return ManagedSettingMeta(overridable, source) def to_dict(self) -> dict: result: dict = {} - if self.error_message is not None: - result["errorMessage"] = from_union([from_str, from_none], self.error_message) - if self.settings_json is not None: - result["settingsJson"] = self.settings_json + result["overridable"] = from_bool(self.overridable) + result["source"] = from_str(self.source) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +class ManagedSettingsChannel(Enum): + """A channel accepted by managedSettings.compose. + + The channel whose candidate document is being supplied. + """ + DEVICE = "device" + POLICY_HELPER = "policyHelper" + SERVER = "server" + +# Experimental: this type is part of an experimental API and may change or be removed. +class ManagedSettingsDiagnosticSeverity(Enum): + """Whether the finding rejects the document. + + Severity of a managed-settings validation finding. + """ + ERROR = "error" + WARNING = "warning" + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsLayer: + """One managed-settings channel and the document it delivered.""" + + source: str + """Channel identifier: `device` (MDM, plist, registry, or managed file), `server` (account + or organization policy), or `policyHelper` (session-local helper output, supported by + compose). Treat unknown output values as additional channels; more may be added. + """ + settings: Any = None + """Validated managed-settings document this channel delivered. Absent when the channel + delivered none. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsLayer': + assert isinstance(obj, dict) + source = from_str(obj.get("source")) + settings = obj.get("settings") + return ManagedSettingsLayer(source, settings) + + def to_dict(self) -> dict: + result: dict = {} + result["source"] = from_str(self.source) + if self.settings is not None: + result["settings"] = self.settings return result # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ManagedSettingsResolvedData: - """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. + """Effective managed settings, in the same shape as `session.managedSettings.get`. + + Effective enterprise managed settings and contributing channels. Session events report + applied policy; sessionless resolve reports an account/device snapshot, and compose + reports a non-applying preview of candidate documents. Device values take precedence over + server values, then the policy helper, per ordinary key, while permissions compose + restrictively. Session-local SDK-client policy is included only in session results. + Marked experimental while the managed-settings surface stabilizes. + + Effective managed settings from the device and account (server) channels, in the same + shape as `session.managedSettings.get`, excluding session-local injection. """ bypass_permissions_disabled: bool """Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. @@ -5712,6 +5765,134 @@ def to_dict(self) -> dict: result["settings"] = self.settings return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsValues: + """Typed effective values, as in `managedSettings.resolve`. + + Typed effective values of managed settings. Each field mirrors the managed-settings + schema key of the same name; more keys are added as they are typed. + + Typed effective values of managed settings, keyed like the managed-settings schema and + already resolved across channels, with the `{ "overridable": ... }` wrapper removed. + Present when policy sets at least one typed key. Keys not typed here are available in + `resolved.settings`. + """ + auto_tier: AutoTier | None = None + """Managed Auto routing preference, used when the selected model is `auto`.""" + + model: str | None = None + """Managed default model identifier, as configured. New sessions start with it; it can name + a model the account cannot use, so hosts match it against the listed models. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsValues': + assert isinstance(obj, dict) + auto_tier = from_union([AutoTier, from_none], obj.get("autoTier")) + model = from_union([from_str, from_none], obj.get("model")) + return ManagedSettingsValues(auto_tier, model) + + 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.model is not None: + result["model"] = from_union([from_str, from_none], self.model) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsReadResult: + """Validated device-managed settings discovered before a session exists.""" + + error_message: str | None = None + """Discovery or validation error text when managed settings could not be read safely.""" + + settings_json: Any = None + """Validated, canonical managed-settings JSON. Omitted when no managed settings were + discovered or when discovered settings failed validation. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsReadResult': + assert isinstance(obj, dict) + error_message = from_union([from_str, from_none], obj.get("errorMessage")) + settings_json = obj.get("settingsJson") + return ManagedSettingsReadResult(error_message, settings_json) + + def to_dict(self) -> dict: + result: dict = {} + if self.error_message is not None: + result["errorMessage"] = from_union([from_str, from_none], self.error_message) + if self.settings_json is not None: + result["settingsJson"] = self.settings_json + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsResolveRequest: + client_name: str | None = None + """Embedding client identity for server policy requests, as in session creation. Omit for + the CLI identity. + """ + git_hub_token: str | None = None + """GitHub token to resolve instead of the current account. The call fails when the token + cannot be resolved. + """ + selection_id: str | None = None + """Opaque account identifier returned by `account.getAllUsers`. When omitted, the current + account is used, or device policy only when no account is signed in. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsResolveRequest': + assert isinstance(obj, dict) + client_name = from_union([from_str, from_none], obj.get("clientName")) + git_hub_token = from_union([from_str, from_none], obj.get("gitHubToken")) + selection_id = from_union([from_str, from_none], obj.get("selectionId")) + return ManagedSettingsResolveRequest(client_name, git_hub_token, selection_id) + + def to_dict(self) -> dict: + result: dict = {} + if self.client_name is not None: + result["clientName"] = from_union([from_str, from_none], self.client_name) + if self.git_hub_token is not None: + result["gitHubToken"] = from_union([from_str, from_none], self.git_hub_token) + if self.selection_id is not None: + result["selectionId"] = from_union([from_str, from_none], self.selection_id) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsValidateRequest: + """A candidate managed-settings document to validate without applying it.""" + + content: Any + """The document to validate: a JSON object, or a string containing the document's JSON text. + Preview documents are limited to 1 MiB and 64 levels of nesting, a stricter resource + limit than delivered-policy parsing; violations are returned as diagnostics. + """ + layer: str | None = None + """Channel the document is meant for (`device`, `server`, or `policyHelper`). Some keys are + only honored in some channels; for example, a `policyHelper` registration is ignored in + policy-helper output. When omitted, no channel-specific checks run. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsValidateRequest': + assert isinstance(obj, dict) + content = obj.get("content") + layer = from_union([from_str, from_none], obj.get("layer")) + return ManagedSettingsValidateRequest(content, layer) + + def to_dict(self) -> dict: + result: dict = {} + result["content"] = self.content + if self.layer is not None: + result["layer"] = from_union([from_str, from_none], self.layer) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MarketplaceAddResult: @@ -22549,6 +22730,95 @@ def to_dict(self) -> dict: result["url"] = from_union([from_str, from_none], self.url) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsComposeLayer: + """One candidate channel; absent settings represents a channel that delivered no document.""" + + source: ManagedSettingsChannel + """The channel whose candidate document is being supplied.""" + + settings: Any = None + """Candidate managed-settings document. Omit when the channel delivered none, as in resolve + output. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsComposeLayer': + assert isinstance(obj, dict) + source = ManagedSettingsChannel(obj.get("source")) + settings = obj.get("settings") + return ManagedSettingsComposeLayer(source, settings) + + def to_dict(self) -> dict: + result: dict = {} + result["source"] = to_enum(ManagedSettingsChannel, self.source) + if self.settings is not None: + result["settings"] = self.settings + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsDiagnostic: + """One validation finding for a managed-settings document.""" + + message: str + """Human-readable description of the finding.""" + + path: str + """Dot-separated path of the offending setting, such as `autoTier.overridable`. Empty for + the document as a whole. + """ + severity: ManagedSettingsDiagnosticSeverity + """Whether the finding rejects the document.""" + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsDiagnostic': + assert isinstance(obj, dict) + message = from_str(obj.get("message")) + path = from_str(obj.get("path")) + severity = ManagedSettingsDiagnosticSeverity(obj.get("severity")) + return ManagedSettingsDiagnostic(message, path, severity) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = from_str(self.message) + result["path"] = from_str(self.path) + result["severity"] = to_enum(ManagedSettingsDiagnosticSeverity, self.severity) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsMeta: + """Per-key lock state and provenance for `values`. + + Per-key lock state and provenance for `ManagedSettingsValues`, with the same field names. + Producers emit each typed key in values and meta together; both outer objects are omitted + when no typed key is set. + + Per-key lock state and provenance for the entries in `values`, using the same key names. + """ + auto_tier: ManagedSettingMeta | None = None + """Lock state and provenance of `values.autoTier`.""" + + model: ManagedSettingMeta | None = None + """Lock state and provenance of `values.model`.""" + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsMeta': + assert isinstance(obj, dict) + auto_tier = from_union([ManagedSettingMeta.from_dict, from_none], obj.get("autoTier")) + model = from_union([ManagedSettingMeta.from_dict, from_none], obj.get("model")) + return ManagedSettingsMeta(auto_tier, model) + + def to_dict(self) -> dict: + result: dict = {} + if self.auto_tier is not None: + result["autoTier"] = from_union([lambda x: to_class(ManagedSettingMeta, x), from_none], self.auto_tier) + if self.model is not None: + result["model"] = from_union([lambda x: to_class(ManagedSettingMeta, x), from_none], self.model) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MarketplaceListResult: @@ -32971,6 +33241,167 @@ def to_dict(self) -> dict: result["name"] = from_union([from_str, from_none], self.name) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsComposeRequest: + """Candidate managed-settings documents to merge without applying them.""" + + layers: list[ManagedSettingsComposeLayer] + """One entry per channel. `source` must be `device`, `server`, or `policyHelper`, each at + most once (checked at runtime); order does not matter, because channel precedence is + fixed. To preview documents from resolve output, map recognized source strings to + ManagedSettingsChannel and copy their settings; generated resolve and compose layer types + are distinct. Omitted settings means this channel delivered no document. Supplied + documents must be valid within the preview limits; warnings are returned in diagnostics. + Compose does not reproduce source-failure state or retained enforcement floors from + resolve. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsComposeRequest': + assert isinstance(obj, dict) + layers = from_list(ManagedSettingsComposeLayer.from_dict, obj.get("layers")) + return ManagedSettingsComposeRequest(layers) + + def to_dict(self) -> dict: + result: dict = {} + result["layers"] = from_list(lambda x: to_class(ManagedSettingsComposeLayer, x), self.layers) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsValidateResult: + """Result of validating a managed-settings document.""" + + diagnostics: list[ManagedSettingsDiagnostic] + """Errors that reject the document and warnings about content the runtime ignores.""" + + valid: bool + """Whether the runtime would accept the document within the preview resource limits. Always + equals whether `settings` is present. An invalid document is rejected as a whole. + """ + settings: Any = None + """Canonical form of the document the runtime would apply, with unrecognized keys removed. + Absent when the document is invalid. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsValidateResult': + assert isinstance(obj, dict) + diagnostics = from_list(ManagedSettingsDiagnostic.from_dict, obj.get("diagnostics")) + valid = from_bool(obj.get("valid")) + settings = obj.get("settings") + return ManagedSettingsValidateResult(diagnostics, valid, settings) + + def to_dict(self) -> dict: + result: dict = {} + result["diagnostics"] = from_list(lambda x: to_class(ManagedSettingsDiagnostic, x), self.diagnostics) + result["valid"] = from_bool(self.valid) + if self.settings is not None: + result["settings"] = self.settings + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsComposeResult: + """The effective managed settings the runtime would enforce for the given documents, in the + same shape `managedSettings.resolve` returns. + """ + diagnostics: list[ManagedSettingsDiagnostic] + """Warnings about ignored content, with paths prefixed by the channel name.""" + + layers: list[ManagedSettingsLayer] + """Only the supplied channels, strongest first, with canonical documents. Empty canonical + documents are represented as absent settings, as in live resolution. + """ + resolved: ManagedSettingsResolvedData + """Effective managed settings, in the same shape as `session.managedSettings.get`.""" + + meta: ManagedSettingsMeta | None = None + """Per-key lock state and provenance for `values`.""" + + values: ManagedSettingsValues | None = None + """Typed effective values, as in `managedSettings.resolve`.""" + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsComposeResult': + assert isinstance(obj, dict) + diagnostics = from_list(ManagedSettingsDiagnostic.from_dict, obj.get("diagnostics")) + layers = from_list(ManagedSettingsLayer.from_dict, obj.get("layers")) + resolved = ManagedSettingsResolvedData.from_dict(obj.get("resolved")) + meta = from_union([ManagedSettingsMeta.from_dict, from_none], obj.get("meta")) + values = from_union([ManagedSettingsValues.from_dict, from_none], obj.get("values")) + return ManagedSettingsComposeResult(diagnostics, layers, resolved, meta, values) + + def to_dict(self) -> dict: + result: dict = {} + result["diagnostics"] = from_list(lambda x: to_class(ManagedSettingsDiagnostic, x), self.diagnostics) + result["layers"] = from_list(lambda x: to_class(ManagedSettingsLayer, x), self.layers) + result["resolved"] = to_class(ManagedSettingsResolvedData, self.resolved) + if self.meta is not None: + result["meta"] = from_union([lambda x: to_class(ManagedSettingsMeta, x), from_none], self.meta) + if self.values is not None: + result["values"] = from_union([lambda x: to_class(ManagedSettingsValues, x), from_none], self.values) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsResolveResult: + """Effective enterprise managed settings for an account, resolved without a session.""" + + diagnostics: list[ManagedSettingsDiagnostic] + """Warnings about unavailable policy sources or a failed refresh served from cache. A cached + response is not proof of a successful live fetch; `resolved.failClosed` separately + describes enforcement. + """ + layers: list[ManagedSettingsLayer] + """Each managed-settings channel consulted, strongest first, with the validated document it + delivered before merging. `resolved.settings` is the merged result. More channels may be + added over time. + """ + resolved: ManagedSettingsResolvedData + """Effective managed settings from the device and account (server) channels, in the same + shape as `session.managedSettings.get`, excluding session-local injection. + """ + account: str | None = None + """Printable opaque identity of the account the settings were resolved for, suitable for + comparison and storage, not an account selectionId. Absent when no account was available, + in which case only device policy is reported. + """ + meta: ManagedSettingsMeta | None = None + """Per-key lock state and provenance for the entries in `values`, using the same key names.""" + + values: ManagedSettingsValues | None = None + """Typed effective values of managed settings, keyed like the managed-settings schema and + already resolved across channels, with the `{ "overridable": ... }` wrapper removed. + Present when policy sets at least one typed key. Keys not typed here are available in + `resolved.settings`. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsResolveResult': + assert isinstance(obj, dict) + diagnostics = from_list(ManagedSettingsDiagnostic.from_dict, obj.get("diagnostics")) + layers = from_list(ManagedSettingsLayer.from_dict, obj.get("layers")) + resolved = ManagedSettingsResolvedData.from_dict(obj.get("resolved")) + account = from_union([from_str, from_none], obj.get("account")) + meta = from_union([ManagedSettingsMeta.from_dict, from_none], obj.get("meta")) + values = from_union([ManagedSettingsValues.from_dict, from_none], obj.get("values")) + return ManagedSettingsResolveResult(diagnostics, layers, resolved, account, meta, values) + + def to_dict(self) -> dict: + result: dict = {} + result["diagnostics"] = from_list(lambda x: to_class(ManagedSettingsDiagnostic, x), self.diagnostics) + result["layers"] = from_list(lambda x: to_class(ManagedSettingsLayer, x), self.layers) + result["resolved"] = to_class(ManagedSettingsResolvedData, self.resolved) + if self.account is not None: + result["account"] = from_union([from_str, from_none], self.account) + if self.meta is not None: + result["meta"] = from_union([lambda x: to_class(ManagedSettingsMeta, x), from_none], self.meta) + if self.values is not None: + result["values"] = from_union([lambda x: to_class(ManagedSettingsValues, x), from_none], self.values) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPAppsHostContext: @@ -42110,6 +42541,33 @@ def to_dict(self) -> dict: result["copilotUser"] = from_union([lambda x: to_class(CopilotUserResponse, x), from_none], self.copilot_user) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedSettingsSchemaResult: + """The authoring JSON schema for managed settings recognized by this runtime.""" + + runtime_version: str + """Version of the runtime that owns this schema.""" + + schema: Any + """JSON schema (draft 2020-12) with descriptive shared `x-composition` annotations, not a + complete runtime composition contract. Model, effortLevel, and contextTier remain + coupled; use `managedSettings.compose` for the runtime's effective result. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ManagedSettingsSchemaResult': + assert isinstance(obj, dict) + runtime_version = from_str(obj.get("runtimeVersion")) + schema = obj.get("schema") + return ManagedSettingsSchemaResult(runtime_version, schema) + + def to_dict(self) -> dict: + result: dict = {} + result["runtimeVersion"] = from_str(self.runtime_version) + result["schema"] = self.schema + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPExecuteSamplingParams: @@ -45886,8 +46344,23 @@ class RPC: log_result: LogResult lsp_initialize_request: LspInitializeRequest managed_mcp_server_config: ManagedMCPServerConfig + managed_setting_meta: ManagedSettingMeta + managed_settings_channel: ManagedSettingsChannel + managed_settings_compose_layer: ManagedSettingsComposeLayer + managed_settings_compose_request: ManagedSettingsComposeRequest + managed_settings_compose_result: ManagedSettingsComposeResult + managed_settings_diagnostic: ManagedSettingsDiagnostic + managed_settings_diagnostic_severity: ManagedSettingsDiagnosticSeverity + managed_settings_layer: ManagedSettingsLayer + managed_settings_meta: ManagedSettingsMeta managed_settings_read_result: ManagedSettingsReadResult managed_settings_resolved_data: ManagedSettingsResolvedData + managed_settings_resolve_request: ManagedSettingsResolveRequest + managed_settings_resolve_result: ManagedSettingsResolveResult + managed_settings_schema_result: ManagedSettingsSchemaResult + managed_settings_validate_request: ManagedSettingsValidateRequest + managed_settings_validate_result: ManagedSettingsValidateResult + managed_settings_values: ManagedSettingsValues marketplace_add_result: MarketplaceAddResult marketplace_browse_result: MarketplaceBrowseResult marketplace_info: MarketplaceInfo @@ -47355,8 +47828,23 @@ def from_dict(obj: Any) -> 'RPC': log_result = LogResult.from_dict(obj.get("LogResult")) lsp_initialize_request = LspInitializeRequest.from_dict(obj.get("LspInitializeRequest")) managed_mcp_server_config = ManagedMCPServerConfig.from_dict(obj.get("ManagedMcpServerConfig")) + managed_setting_meta = ManagedSettingMeta.from_dict(obj.get("ManagedSettingMeta")) + managed_settings_channel = ManagedSettingsChannel(obj.get("ManagedSettingsChannel")) + managed_settings_compose_layer = ManagedSettingsComposeLayer.from_dict(obj.get("ManagedSettingsComposeLayer")) + managed_settings_compose_request = ManagedSettingsComposeRequest.from_dict(obj.get("ManagedSettingsComposeRequest")) + managed_settings_compose_result = ManagedSettingsComposeResult.from_dict(obj.get("ManagedSettingsComposeResult")) + managed_settings_diagnostic = ManagedSettingsDiagnostic.from_dict(obj.get("ManagedSettingsDiagnostic")) + managed_settings_diagnostic_severity = ManagedSettingsDiagnosticSeverity(obj.get("ManagedSettingsDiagnosticSeverity")) + managed_settings_layer = ManagedSettingsLayer.from_dict(obj.get("ManagedSettingsLayer")) + managed_settings_meta = ManagedSettingsMeta.from_dict(obj.get("ManagedSettingsMeta")) managed_settings_read_result = ManagedSettingsReadResult.from_dict(obj.get("ManagedSettingsReadResult")) managed_settings_resolved_data = ManagedSettingsResolvedData.from_dict(obj.get("ManagedSettingsResolvedData")) + managed_settings_resolve_request = ManagedSettingsResolveRequest.from_dict(obj.get("ManagedSettingsResolveRequest")) + managed_settings_resolve_result = ManagedSettingsResolveResult.from_dict(obj.get("ManagedSettingsResolveResult")) + managed_settings_schema_result = ManagedSettingsSchemaResult.from_dict(obj.get("ManagedSettingsSchemaResult")) + managed_settings_validate_request = ManagedSettingsValidateRequest.from_dict(obj.get("ManagedSettingsValidateRequest")) + managed_settings_validate_result = ManagedSettingsValidateResult.from_dict(obj.get("ManagedSettingsValidateResult")) + managed_settings_values = ManagedSettingsValues.from_dict(obj.get("ManagedSettingsValues")) marketplace_add_result = MarketplaceAddResult.from_dict(obj.get("MarketplaceAddResult")) marketplace_browse_result = MarketplaceBrowseResult.from_dict(obj.get("MarketplaceBrowseResult")) marketplace_info = MarketplaceInfo.from_dict(obj.get("MarketplaceInfo")) @@ -48381,7 +48869,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, accepted_enqueue_command_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_kind, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, accounts_enumerate_request, accounts_get_request, accounts_set_request, account_status, 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_enumerate_query, auth_enumerate_value, auth_identity, auth_info, auth_info_type, auth_login_advance_request, auth_login_begin_request, auth_login_begun, auth_login_cancel_request, auth_login_result_dto, auth_login_result_status, auth_login_step, auth_read_query, auth_read_value, auth_status_dto, auth_validation_error, auth_validation_errors, auth_write, auth_write_result, autopilot_objective_credit_limit, autopilot_objective_get_state_result, autopilot_objective_state, autopilot_objective_status, 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_agent_plugin_candidate, catalog_agent_plugin_candidate_kind, catalog_agent_plugin_candidate_provenance, catalog_agent_plugin_compatibility_tag, catalog_agent_plugin_media_type, catalog_ai_skill_candidate, catalog_ai_skill_candidate_kind, catalog_ai_skill_candidate_provenance, catalog_ai_skill_installability, catalog_ai_skill_media_type, 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_kind, 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_plugin_repository_source, catalog_policy_rejected_error, catalog_resource_identity, catalog_resource_version, catalog_search_page, catalog_search_pagination, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_search_total_count_relation, catalog_selection_cancelled, catalog_selection_decision, catalog_selection_declined, catalog_selection_foreign, catalog_selection_invalid, catalog_selection_replayed, catalog_selection_request, catalog_selection_result, catalog_selection_selected, catalog_selection_stale, catalog_selection_timed_out, catalog_selection_wrong_kind, catalog_trust_eligibility, catalog_trust_provenance, catalog_trust_snapshot, catalog_trust_snapshot_absent, catalog_trust_snapshot_absent_status, catalog_trust_snapshot_current, catalog_trust_snapshot_current_status, catalog_trust_snapshot_downgraded, catalog_trust_snapshot_downgraded_status, catalog_trust_snapshot_malformed, catalog_trust_snapshot_malformed_status, catalog_trust_snapshot_revoked, catalog_trust_snapshot_revoked_status, catalog_trust_snapshot_schema_version, catalog_trust_snapshot_stale, catalog_trust_snapshot_stale_status, catalog_trust_snapshot_unsupported, catalog_trust_snapshot_unsupported_status, catalog_trust_source, catalog_trust_tier, 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, client_metadata, client_task_cancel_reason, client_task_cancel_request, client_task_cancel_result, 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, connector_account_request, connector_authorization_requirement, connector_authorization_scope, connector_availability, connector_capabilities, connector_catalog_entry, connector_catalog_result, connector_catalog_status, connector_connect_request, connector_connect_result, connector_continue_request, connector_disconnect_result, connector_mcp_status, connector_reconcile_request, connector_runtime_status, connector_status, 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, diagnostic_cursor_status, diagnostic_entry, diagnostic_log_level, diagnostics_configuration, diagnostics_configure_request, diagnostic_severity, diagnostic_source, diagnostic_sources_configuration, diagnostics_read_request, diagnostics_read_result, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_hook, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, entra_token_acquire_request, entra_token_acquire_result, entra_token_interaction, env_auth_info, environment_capabilities, environment_kind, environments_delete_request, environments_delete_result, environments_get_request, environments_get_result, environments_list_request, environments_list_result, 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, 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_environment, 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_origin, hooks_discover_request, hooks_discover_result, hook_type, host_configuration, host_dispose_request, host_empty_result, host_environment_credentials, host_exited_notification, host_exit_reason, host_git_hub_environment_options, host_local_server_configuration, host_local_server_options, host_publish_session_request, host_publish_session_result, host_ready_request, host_register_session_request, host_session_create_callback, host_session_create_request, host_session_create_result, host_session_released_notification, host_session_release_request, host_start_request, host_start_result, installation_catalogue_identity, installation_confirmation_request, installation_confirmation_response, installation_decision, installation_review, 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, json_schema_response_format, 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, login_provider_kind, log_request, log_result, lsp_initialize_request, managed_mcp_server_config, managed_settings_read_result, managed_settings_resolved_data, 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_apply_install_request, mcp_apply_uninstall_request, 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_diagnostic_details, mcp_diagnostic_direction, mcp_diagnostic_kind, mcp_diagnostic_source_configuration, 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_installation_failure_reason, mcp_installation_input, mcp_installation_management_outcome, mcp_installation_management_result, mcp_installation_operation_request, mcp_installation_operation_status, mcp_installation_outcome, mcp_installation_remote_configuration, mcp_installation_result, mcp_installation_review, mcp_installation_secret, mcp_installation_secret_storage, mcp_installations_request, mcp_installation_state, mcp_installation_summary, 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_cancel_login_request, mcp_oauth_cancel_login_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_prepare_login_request, mcp_oauth_prepare_login_result, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_owned_oauth_login_status, 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_uninstall_request, mcp_plan_value_category, mcp_prepared_install, mcp_prepare_install_request, 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_server_ownership, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_source_file, mcp_source_plugin, mcp_source_ref, 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_uninstall_plan, 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, metadata_update_client_metadata_request, 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_provider_descriptor, model_provider_kind, model_provider_ref, model_set_allowed_models_request, model_set_allowed_models_result, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_auto_tier_request, model_switch_auto_tier_result, model_switch_auto_tier_status, 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_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_workflow, 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_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_workflow, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_approve_read_only_for_session, 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_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_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_workflow, 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_install_staging_mode, 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_append_mode, protocol_customize_mode, protocol_external_tool_defer, protocol_external_tool_definition, protocol_marker_section_override, protocol_replace_mode, protocol_section_override, protocol_static_section_action, protocol_static_section_override, protocol_system_message_append_config, protocol_system_message_config, protocol_system_message_customize_config, protocol_system_message_replace_config, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_descriptor, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_sync_request, provider_sync_result, provider_token_acquire_request, provider_token_acquire_result, provider_withdraw_request, provider_withdraw_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_append_steering_request, 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, queue_withdraw_message_request, queue_withdraw_message_result, register_event_interest_params, register_event_interest_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, response_format, 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_credentials_config, sandbox_disable_for_session_request, sandbox_disable_for_session_result, sandbox_enforcement_status, sandbox_grant_path_for_request_request, sandbox_grant_path_for_request_result, sandbox_host_capability, sandbox_host_capability_name, sandbox_host_support, sandbox_masked_env_var, sandbox_session_change, 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_mcp_oauth_cancel_login_request, session_mcp_oauth_cancel_login_result, session_mcp_oauth_prepare_login_request, session_mcp_oauth_prepare_login_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_disable_request, session_plugins_enable_request, session_plugins_install_request, session_plugins_marketplaces_refresh_request, 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_client_metadata_entry, 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_client_metadata_request, sessions_get_client_metadata_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_read_persisted_events_request, 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_workflow_pause_at_checkpoint_result, 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_apply_install_request, skill_apply_uninstall_request, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_installation_failure_reason, skill_installation_file_review, skill_installation_location, skill_installation_management_outcome, skill_installation_management_result, skill_installation_operation_request, skill_installation_operation_status, skill_installation_outcome, skill_installation_ownership_state, skill_installation_result, skill_installation_review, skill_installation_scope, skill_installation_session_state, skill_installation_source, skill_installations_request, skill_installation_summary, skill_install_plan, skill_list, skill_plan_install_request, skill_plan_uninstall_request, skill_provider_descriptor, skill_provider_list_request, skill_provider_list_result, skill_provider_read_request, skill_provider_read_result, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skill_set_enabled_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, skill_uninstall_plan, 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, system_message_block, task_agent_info, task_agent_progress, task_client_active_status, task_client_execution_mode, task_client_info, task_client_owner, task_client_owner_kind, task_client_owner_presence, task_client_progress, task_client_status, task_client_type, task_client_update, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_kind, 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_register_request, tasks_register_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_update_request, tasks_update_result, 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, unsupported_enqueue_command_result, 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, workflow_abort_request, workflow_ack_result, workflow_agent_options, workflow_agent_request, workflow_agent_result, workflow_agent_summary, workflow_cancel_request, workflow_current_phase, workflow_declared_limits, workflow_durable_operation, workflow_execute_request, workflow_execute_result, workflow_get_run_progress_request, workflow_get_run_request, workflow_journal_get_request, workflow_journal_get_result, workflow_journal_put_request, workflow_list_runs_request, workflow_list_runs_result, workflow_log_line, workflow_log_line_kind, workflow_log_request, workflow_pause_checkpoint_action, workflow_pause_checkpoint_request, workflow_pause_checkpoint_result, workflow_pause_info, workflow_pause_request, workflow_phase_observation, workflow_phase_status, workflow_progress_line, workflow_progress_page, workflow_resume_request, workflow_resume_result, workflow_run_consumed, workflow_run_detail, workflow_run_failure, workflow_run_failure_kind, workflow_run_limits, workflow_run_options, workflow_run_request, workflow_run_result, workflow_run_status, workflow_run_summary, workflow_run_terminal, workflow_tool_resume_request, workflow_tool_run_options, workflow_tool_run_request, 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_directory_request, 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_remove_path_request, workspaces_rename_path_request, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_stat_file_request, workspaces_stat_file_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, accepted_enqueue_command_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_kind, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, accounts_enumerate_request, accounts_get_request, accounts_set_request, account_status, 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_enumerate_query, auth_enumerate_value, auth_identity, auth_info, auth_info_type, auth_login_advance_request, auth_login_begin_request, auth_login_begun, auth_login_cancel_request, auth_login_result_dto, auth_login_result_status, auth_login_step, auth_read_query, auth_read_value, auth_status_dto, auth_validation_error, auth_validation_errors, auth_write, auth_write_result, autopilot_objective_credit_limit, autopilot_objective_get_state_result, autopilot_objective_state, autopilot_objective_status, 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_agent_plugin_candidate, catalog_agent_plugin_candidate_kind, catalog_agent_plugin_candidate_provenance, catalog_agent_plugin_compatibility_tag, catalog_agent_plugin_media_type, catalog_ai_skill_candidate, catalog_ai_skill_candidate_kind, catalog_ai_skill_candidate_provenance, catalog_ai_skill_installability, catalog_ai_skill_media_type, 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_kind, 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_plugin_repository_source, catalog_policy_rejected_error, catalog_resource_identity, catalog_resource_version, catalog_search_page, catalog_search_pagination, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_search_total_count_relation, catalog_selection_cancelled, catalog_selection_decision, catalog_selection_declined, catalog_selection_foreign, catalog_selection_invalid, catalog_selection_replayed, catalog_selection_request, catalog_selection_result, catalog_selection_selected, catalog_selection_stale, catalog_selection_timed_out, catalog_selection_wrong_kind, catalog_trust_eligibility, catalog_trust_provenance, catalog_trust_snapshot, catalog_trust_snapshot_absent, catalog_trust_snapshot_absent_status, catalog_trust_snapshot_current, catalog_trust_snapshot_current_status, catalog_trust_snapshot_downgraded, catalog_trust_snapshot_downgraded_status, catalog_trust_snapshot_malformed, catalog_trust_snapshot_malformed_status, catalog_trust_snapshot_revoked, catalog_trust_snapshot_revoked_status, catalog_trust_snapshot_schema_version, catalog_trust_snapshot_stale, catalog_trust_snapshot_stale_status, catalog_trust_snapshot_unsupported, catalog_trust_snapshot_unsupported_status, catalog_trust_source, catalog_trust_tier, 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, client_metadata, client_task_cancel_reason, client_task_cancel_request, client_task_cancel_result, 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, connector_account_request, connector_authorization_requirement, connector_authorization_scope, connector_availability, connector_capabilities, connector_catalog_entry, connector_catalog_result, connector_catalog_status, connector_connect_request, connector_connect_result, connector_continue_request, connector_disconnect_result, connector_mcp_status, connector_reconcile_request, connector_runtime_status, connector_status, 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, diagnostic_cursor_status, diagnostic_entry, diagnostic_log_level, diagnostics_configuration, diagnostics_configure_request, diagnostic_severity, diagnostic_source, diagnostic_sources_configuration, diagnostics_read_request, diagnostics_read_result, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_hook, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, entra_token_acquire_request, entra_token_acquire_result, entra_token_interaction, env_auth_info, environment_capabilities, environment_kind, environments_delete_request, environments_delete_result, environments_get_request, environments_get_result, environments_list_request, environments_list_result, 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, 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_environment, 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_origin, hooks_discover_request, hooks_discover_result, hook_type, host_configuration, host_dispose_request, host_empty_result, host_environment_credentials, host_exited_notification, host_exit_reason, host_git_hub_environment_options, host_local_server_configuration, host_local_server_options, host_publish_session_request, host_publish_session_result, host_ready_request, host_register_session_request, host_session_create_callback, host_session_create_request, host_session_create_result, host_session_released_notification, host_session_release_request, host_start_request, host_start_result, installation_catalogue_identity, installation_confirmation_request, installation_confirmation_response, installation_decision, installation_review, 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, json_schema_response_format, 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, login_provider_kind, log_request, log_result, lsp_initialize_request, managed_mcp_server_config, managed_setting_meta, managed_settings_channel, managed_settings_compose_layer, managed_settings_compose_request, managed_settings_compose_result, managed_settings_diagnostic, managed_settings_diagnostic_severity, managed_settings_layer, managed_settings_meta, managed_settings_read_result, managed_settings_resolved_data, managed_settings_resolve_request, managed_settings_resolve_result, managed_settings_schema_result, managed_settings_validate_request, managed_settings_validate_result, managed_settings_values, 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_apply_install_request, mcp_apply_uninstall_request, 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_diagnostic_details, mcp_diagnostic_direction, mcp_diagnostic_kind, mcp_diagnostic_source_configuration, 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_installation_failure_reason, mcp_installation_input, mcp_installation_management_outcome, mcp_installation_management_result, mcp_installation_operation_request, mcp_installation_operation_status, mcp_installation_outcome, mcp_installation_remote_configuration, mcp_installation_result, mcp_installation_review, mcp_installation_secret, mcp_installation_secret_storage, mcp_installations_request, mcp_installation_state, mcp_installation_summary, 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_cancel_login_request, mcp_oauth_cancel_login_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_prepare_login_request, mcp_oauth_prepare_login_result, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_owned_oauth_login_status, 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_uninstall_request, mcp_plan_value_category, mcp_prepared_install, mcp_prepare_install_request, 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_server_ownership, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_source_file, mcp_source_plugin, mcp_source_ref, 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_uninstall_plan, 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, metadata_update_client_metadata_request, 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_provider_descriptor, model_provider_kind, model_provider_ref, model_set_allowed_models_request, model_set_allowed_models_result, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_auto_tier_request, model_switch_auto_tier_result, model_switch_auto_tier_status, 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_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_workflow, 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_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_workflow, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_approve_read_only_for_session, 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_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_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_workflow, 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_install_staging_mode, 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_append_mode, protocol_customize_mode, protocol_external_tool_defer, protocol_external_tool_definition, protocol_marker_section_override, protocol_replace_mode, protocol_section_override, protocol_static_section_action, protocol_static_section_override, protocol_system_message_append_config, protocol_system_message_config, protocol_system_message_customize_config, protocol_system_message_replace_config, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_descriptor, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_sync_request, provider_sync_result, provider_token_acquire_request, provider_token_acquire_result, provider_withdraw_request, provider_withdraw_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_append_steering_request, 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, queue_withdraw_message_request, queue_withdraw_message_result, register_event_interest_params, register_event_interest_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, response_format, 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_credentials_config, sandbox_disable_for_session_request, sandbox_disable_for_session_result, sandbox_enforcement_status, sandbox_grant_path_for_request_request, sandbox_grant_path_for_request_result, sandbox_host_capability, sandbox_host_capability_name, sandbox_host_support, sandbox_masked_env_var, sandbox_session_change, 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_mcp_oauth_cancel_login_request, session_mcp_oauth_cancel_login_result, session_mcp_oauth_prepare_login_request, session_mcp_oauth_prepare_login_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_disable_request, session_plugins_enable_request, session_plugins_install_request, session_plugins_marketplaces_refresh_request, 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_client_metadata_entry, 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_client_metadata_request, sessions_get_client_metadata_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_read_persisted_events_request, 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_workflow_pause_at_checkpoint_result, 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_apply_install_request, skill_apply_uninstall_request, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_installation_failure_reason, skill_installation_file_review, skill_installation_location, skill_installation_management_outcome, skill_installation_management_result, skill_installation_operation_request, skill_installation_operation_status, skill_installation_outcome, skill_installation_ownership_state, skill_installation_result, skill_installation_review, skill_installation_scope, skill_installation_session_state, skill_installation_source, skill_installations_request, skill_installation_summary, skill_install_plan, skill_list, skill_plan_install_request, skill_plan_uninstall_request, skill_provider_descriptor, skill_provider_list_request, skill_provider_list_result, skill_provider_read_request, skill_provider_read_result, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skill_set_enabled_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, skill_uninstall_plan, 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, system_message_block, task_agent_info, task_agent_progress, task_client_active_status, task_client_execution_mode, task_client_info, task_client_owner, task_client_owner_kind, task_client_owner_presence, task_client_progress, task_client_status, task_client_type, task_client_update, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_kind, 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_register_request, tasks_register_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_update_request, tasks_update_result, 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, unsupported_enqueue_command_result, 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, workflow_abort_request, workflow_ack_result, workflow_agent_options, workflow_agent_request, workflow_agent_result, workflow_agent_summary, workflow_cancel_request, workflow_current_phase, workflow_declared_limits, workflow_durable_operation, workflow_execute_request, workflow_execute_result, workflow_get_run_progress_request, workflow_get_run_request, workflow_journal_get_request, workflow_journal_get_result, workflow_journal_put_request, workflow_list_runs_request, workflow_list_runs_result, workflow_log_line, workflow_log_line_kind, workflow_log_request, workflow_pause_checkpoint_action, workflow_pause_checkpoint_request, workflow_pause_checkpoint_result, workflow_pause_info, workflow_pause_request, workflow_phase_observation, workflow_phase_status, workflow_progress_line, workflow_progress_page, workflow_resume_request, workflow_resume_result, workflow_run_consumed, workflow_run_detail, workflow_run_failure, workflow_run_failure_kind, workflow_run_limits, workflow_run_options, workflow_run_request, workflow_run_result, workflow_run_status, workflow_run_summary, workflow_run_terminal, workflow_tool_resume_request, workflow_tool_run_options, workflow_tool_run_request, 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_directory_request, 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_remove_path_request, workspaces_rename_path_request, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_stat_file_request, workspaces_stat_file_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 = {} @@ -48824,8 +49312,23 @@ def to_dict(self) -> dict: result["LogResult"] = to_class(LogResult, self.log_result) result["LspInitializeRequest"] = to_class(LspInitializeRequest, self.lsp_initialize_request) result["ManagedMcpServerConfig"] = to_class(ManagedMCPServerConfig, self.managed_mcp_server_config) + result["ManagedSettingMeta"] = to_class(ManagedSettingMeta, self.managed_setting_meta) + result["ManagedSettingsChannel"] = to_enum(ManagedSettingsChannel, self.managed_settings_channel) + result["ManagedSettingsComposeLayer"] = to_class(ManagedSettingsComposeLayer, self.managed_settings_compose_layer) + result["ManagedSettingsComposeRequest"] = to_class(ManagedSettingsComposeRequest, self.managed_settings_compose_request) + result["ManagedSettingsComposeResult"] = to_class(ManagedSettingsComposeResult, self.managed_settings_compose_result) + result["ManagedSettingsDiagnostic"] = to_class(ManagedSettingsDiagnostic, self.managed_settings_diagnostic) + result["ManagedSettingsDiagnosticSeverity"] = to_enum(ManagedSettingsDiagnosticSeverity, self.managed_settings_diagnostic_severity) + result["ManagedSettingsLayer"] = to_class(ManagedSettingsLayer, self.managed_settings_layer) + result["ManagedSettingsMeta"] = to_class(ManagedSettingsMeta, self.managed_settings_meta) result["ManagedSettingsReadResult"] = to_class(ManagedSettingsReadResult, self.managed_settings_read_result) result["ManagedSettingsResolvedData"] = to_class(ManagedSettingsResolvedData, self.managed_settings_resolved_data) + result["ManagedSettingsResolveRequest"] = to_class(ManagedSettingsResolveRequest, self.managed_settings_resolve_request) + result["ManagedSettingsResolveResult"] = to_class(ManagedSettingsResolveResult, self.managed_settings_resolve_result) + result["ManagedSettingsSchemaResult"] = to_class(ManagedSettingsSchemaResult, self.managed_settings_schema_result) + result["ManagedSettingsValidateRequest"] = to_class(ManagedSettingsValidateRequest, self.managed_settings_validate_request) + result["ManagedSettingsValidateResult"] = to_class(ManagedSettingsValidateResult, self.managed_settings_validate_result) + result["ManagedSettingsValues"] = to_class(ManagedSettingsValues, self.managed_settings_values) result["MarketplaceAddResult"] = to_class(MarketplaceAddResult, self.marketplace_add_result) result["MarketplaceBrowseResult"] = to_class(MarketplaceBrowseResult, self.marketplace_browse_result) result["MarketplaceInfo"] = to_class(MarketplaceInfo, self.marketplace_info) @@ -50874,13 +51377,32 @@ def __init__(self, client: "JsonRpcClient"): self._client = client async def read(self, *, timeout: float | None = None) -> ManagedSettingsReadResult: - "Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session.\n\nReturns:\n Validated device-managed settings discovered before a session exists." + "Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session. `managedSettings.resolve` returns the same device settings together with the account's server policy.\n\nReturns:\n Validated device-managed settings discovered before a session exists." return ManagedSettingsReadResult.from_dict(await self._client.request("managedSettings.read", {}, **_timeout_kwargs(timeout))) async def clear_cache(self, *, timeout: float | None = None) -> None: - "Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing \"sync account policy\" should start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed." + "Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing \"sync account policy\" should call `managedSettings.resolve` or start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed." await self._client.request("managedSettings.clearCache", {}, **_timeout_kwargs(timeout)) + async def resolve(self, params: ManagedSettingsResolveRequest, *, timeout: float | None = None) -> ManagedSettingsResolveResult: + "Resolves the effective enterprise managed settings without a session, from the device channel and, when an account is available, the account's server policy through the same per-account cache sessions use. A cached server policy less than an hour old is used without a fetch; otherwise the policy is fetched, and when the fetch fails a cached policy up to 24 hours old is used instead, unless `forceRemoteSettingsRefresh` requires a live fetch. With no account requested or signed in, it reports device policy only; signing out removes the account's cached policy. It can fetch server policy over the network when the cache is stale, so call it off latency-critical paths such as startup rather than before listing models. The policy helper is not run. `layers` lists each channel's document before merging, and `values` and `meta` carry typed effective values and their lock state for the keys typed so far.\n\nArgs:\n params: Optional opaque account selection or GitHub token whose managed settings are resolved.\n\nReturns:\n Effective enterprise managed settings for an account, resolved without a session." + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return ManagedSettingsResolveResult.from_dict(await self._client.request("managedSettings.resolve", params_dict, **_timeout_kwargs(timeout))) + + async def schema(self, *, timeout: float | None = None) -> ManagedSettingsSchemaResult: + "Returns the managed-settings authoring JSON schema with descriptive `x-composition` annotations aligned with the shared settings-engine vocabulary. These annotations are not a complete runtime composition contract: model, effortLevel, and contextTier remain coupled. Use `managedSettings.compose` for the runtime's effective result. Performs no I/O.\n\nReturns:\n The authoring JSON schema for managed settings recognized by this runtime." + return ManagedSettingsSchemaResult.from_dict(await self._client.request("managedSettings.schema", {}, **_timeout_kwargs(timeout))) + + async def validate(self, params: ManagedSettingsValidateRequest, *, timeout: float | None = None) -> ManagedSettingsValidateResult: + "Validates a candidate managed-settings document the way the runtime validates delivered policy, without applying it. Reports errors that would reject the document, warnings for content the runtime ignores, and the canonical document it would apply. Document text nested more than 64 levels deep is rejected. Performs no I/O.\n\nArgs:\n params: A candidate managed-settings document to validate without applying it.\n\nReturns:\n Result of validating a managed-settings document." + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return ManagedSettingsValidateResult.from_dict(await self._client.request("managedSettings.validate", params_dict, **_timeout_kwargs(timeout))) + + async def compose(self, params: ManagedSettingsComposeRequest, *, timeout: float | None = None) -> ManagedSettingsComposeResult: + "Merges candidate managed-settings documents for the device, server, and policy-helper channels into the effective settings the runtime would enforce on this host, using the same precedence and composition rules as live resolution, without applying them. Like live resolution, a server's advisory sandbox force-enable is declined on a host that cannot run the sandbox. Does not fetch policy or read policy files, but may perform blocking OS or subprocess probes for sandbox support. Preview documents are limited to 1 MiB and 64 levels of nesting.\n\nArgs:\n params: Candidate managed-settings documents to merge without applying them.\n\nReturns:\n The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns." + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return ManagedSettingsComposeResult.from_dict(await self._client.request("managedSettings.compose", params_dict, **_timeout_kwargs(timeout))) + # Experimental: this API group is experimental and may change or be removed. class ServerRuntimeApi: @@ -52165,7 +52687,7 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._session_id = session_id async def get(self, *, timeout: float | None = None) -> ManagedSettingsResolvedData: - "Waits for the live session's in-flight managed-settings application, then returns the retained effective snapshot used by runtime enforcement and by `session.managed_settings_resolved`. It does not perform another account, device, or server resolution, and rejects when resolution has not produced a snapshot.\n\nReturns:\n 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." + "Waits for the live session's in-flight managed-settings application, then returns the retained effective snapshot used by runtime enforcement and by `session.managed_settings_resolved`. It does not perform another account, device, or server resolution, and rejects when resolution has not produced a snapshot.\n\nReturns:\n Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes." return ManagedSettingsResolvedData.from_dict(await self._client.request("session.managedSettings.get", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) @@ -54543,9 +55065,24 @@ async def handle_installations_confirm(params: dict) -> dict | None: "MCPUninstallPlan", "MCPUnregisterExternalClientRequest", "ManagedMCPServerConfig", + "ManagedSettingMeta", "ManagedSettingsApi", + "ManagedSettingsChannel", + "ManagedSettingsComposeLayer", + "ManagedSettingsComposeRequest", + "ManagedSettingsComposeResult", + "ManagedSettingsDiagnostic", + "ManagedSettingsDiagnosticSeverity", + "ManagedSettingsLayer", + "ManagedSettingsMeta", "ManagedSettingsReadResult", + "ManagedSettingsResolveRequest", + "ManagedSettingsResolveResult", "ManagedSettingsResolvedData", + "ManagedSettingsSchemaResult", + "ManagedSettingsValidateRequest", + "ManagedSettingsValidateResult", + "ManagedSettingsValues", "MarketplaceAddResult", "MarketplaceBrowseResult", "MarketplaceInfo", diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 0e3ccdc828..eb0364d9ea 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -2429,7 +2429,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, 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." + "Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes." bypass_permissions_disabled: bool device_managed: bool fail_closed: bool diff --git a/python/e2e/test_client_e2e.py b/python/e2e/test_client_e2e.py index a42c31885c..bc7da64485 100644 --- a/python/e2e/test_client_e2e.py +++ b/python/e2e/test_client_e2e.py @@ -12,7 +12,7 @@ ) from copilot.session import PermissionHandler -from .testharness import CLI_PATH +from .testharness import CLI_PATH, DEFAULT_GITHUB_TOKEN, E2ETestContext class TestClient: @@ -114,56 +114,60 @@ async def test_should_get_auth_status(self): await client.force_stop() @pytest.mark.asyncio - async def test_should_list_models_when_authenticated(self): - client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + async def test_should_list_models_when_authenticated(self, ctx: E2ETestContext): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + env=ctx.get_env(), + working_directory=ctx.work_dir, + github_token=DEFAULT_GITHUB_TOKEN, + ) try: await client.start() auth_status = await client.get_auth_status() - if not auth_status.isAuthenticated: - # Skip if not authenticated - models.list requires auth - await client.stop() - return + assert auth_status.isAuthenticated models = await client.list_models() assert isinstance(models, list) - if len(models) > 0: - model = models[0] - assert hasattr(model, "id") - assert hasattr(model, "name") - assert hasattr(model, "capabilities") - assert hasattr(model.capabilities, "supports") - assert hasattr(model.capabilities, "limits") + assert models + model = models[0] + assert hasattr(model, "id") + assert hasattr(model, "name") + assert hasattr(model, "capabilities") + assert hasattr(model.capabilities, "supports") + assert hasattr(model.capabilities, "limits") await client.stop() finally: await client.force_stop() @pytest.mark.asyncio - async def test_should_cache_models_list(self): + async def test_should_cache_models_list(self, ctx: E2ETestContext): """Test that list_models caches results to avoid rate limiting""" - client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + env=ctx.get_env(), + working_directory=ctx.work_dir, + github_token=DEFAULT_GITHUB_TOKEN, + ) try: await client.start() auth_status = await client.get_auth_status() - if not auth_status.isAuthenticated: - # Skip if not authenticated - models.list requires auth - await client.stop() - return + assert auth_status.isAuthenticated # First call should fetch from backend models1 = await client.list_models() assert isinstance(models1, list) + assert models1 # Second call should return from cache (different list object but same content) models2 = await client.list_models() assert models2 is not models1, "Should return a copy, not the same object" assert len(models2) == len(models1), "Cached results should have same content" - if len(models1) > 0: - assert models1[0].id == models2[0].id, "Cached models should match" + assert models1[0].id == models2[0].id, "Cached models should match" # After stopping, cache should be cleared await client.stop() @@ -173,9 +177,7 @@ async def test_should_cache_models_list(self): # Check authentication again after restart auth_status = await client.get_auth_status() - if not auth_status.isAuthenticated: - await client.stop() - return + assert auth_status.isAuthenticated models3 = await client.list_models() assert models3 is not models1, "Cache should be cleared after disconnect" diff --git a/python/e2e/test_inprocess_ffi_e2e.py b/python/e2e/test_inprocess_ffi_e2e.py index ea82037b7a..89565d414e 100644 --- a/python/e2e/test_inprocess_ffi_e2e.py +++ b/python/e2e/test_inprocess_ffi_e2e.py @@ -20,7 +20,10 @@ class TestInProcessFfi: - async def test_should_start_and_connect_over_in_process_ffi(self, ctx: E2ETestContext): + @pytest.mark.parametrize("shutdown_method", ["stop", "force_stop"]) + async def test_should_start_and_connect_over_in_process_ffi( + self, ctx: E2ETestContext, shutdown_method: str + ): # 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()) @@ -31,4 +34,4 @@ async def test_should_start_and_connect_over_in_process_ffi(self, ctx: E2ETestCo assert pong.message == "pong: ffi message" assert pong.timestamp is not None finally: - await client.stop() + await getattr(client, shutdown_method)() diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py index da70265a04..145d3f68bd 100644 --- a/python/e2e/test_subagent_hooks_e2e.py +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -5,7 +5,9 @@ from __future__ import annotations +import asyncio import os +from uuid import uuid4 import httpx import pytest @@ -70,6 +72,17 @@ async def test_should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool """Test that preToolUse/postToolUse hooks fire for sub-agent tool calls""" hook_log = [] request_handler = _RecordingRequestHandler() + waiting_text = ( + "I've launched an explore agent to read subagent-test.txt. " + "Waiting for it to complete..." + ) + parent_waiting = asyncio.Event() + parent_session_id = str(uuid4()) + final_text = ( + "The explore agent successfully read the file. " + "The contents of **subagent-test.txt** are:\n\n" + "```\nHello from subagent test!\n```" + ) async def on_pre_tool_use(input_data, invocation): hook_log.append( @@ -89,6 +102,12 @@ async def on_post_tool_use(input_data, invocation): "sessionId": input_data.get("sessionId"), } ) + # A fast child can inject its result before the fixture's waiting reply is requested. + if ( + input_data.get("toolName") == "view" + and input_data.get("sessionId") != parent_session_id + ): + await parent_waiting.wait() return None # Create a client with the session-based subagents feature flag @@ -106,6 +125,7 @@ async def on_post_tool_use(input_data, invocation): ) session = await client.create_session( + session_id=parent_session_id, on_permission_request=PermissionHandler.approve_all, hooks={ "on_pre_tool_use": on_pre_tool_use, @@ -116,11 +136,34 @@ async def on_post_tool_use(input_data, invocation): # Create a file for the sub-agent to read write_file(ctx.work_dir, "subagent-test.txt", "Hello from subagent test!") - await session.send_and_wait( - "Use the task tool to spawn an explore agent that reads the file " - "subagent-test.txt in the current directory and reports its contents. " - "You must use the task tool." - ) + def on_event(event): + if ( + not event.agent_id + and event.type.value == "assistant.message" + and event.data.content == waiting_text + ): + parent_waiting.set() + + unsubscribe = session.on(on_event) + try: + response = await session.send_and_wait( + "Use the task tool to spawn an explore agent that reads the file " + "subagent-test.txt in the current directory and reports its contents. " + "You must use the task tool." + ) + assert response is not None and not response.agent_id + assert response.data.content == final_text + replies = [ + event.data.content + for event in await session.get_events() + if not event.agent_id + and event.type.value == "assistant.message" + and event.data.content in (waiting_text, final_text) + ] + assert replies == [waiting_text, final_text] + finally: + parent_waiting.set() + unsubscribe() # Parent tool hooks fire for "task" task_pre = [h for h in hook_log if h["kind"] == "pre" and h["toolName"] == "task"] diff --git a/python/test_ffi_runtime_host.py b/python/test_ffi_runtime_host.py index 5806afa1e1..b142985825 100644 --- a/python/test_ffi_runtime_host.py +++ b/python/test_ffi_runtime_host.py @@ -1,7 +1,11 @@ +import asyncio import threading import time from unittest.mock import patch +import pytest + +from copilot import CopilotClient, RuntimeConnection from copilot._ffi_runtime_host import FfiRuntimeHost @@ -54,3 +58,43 @@ def test_dispose_retains_callback_until_connection_close_succeeds(): time.sleep(0.05) assert library.close_calls == close_calls_after_cleanup assert library.shutdown_calls == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["stop", "force_stop"]) +async def test_client_shutdown_keeps_event_loop_responsive_until_native_cleanup_finishes(method): + loop = asyncio.get_running_loop() + loop_response = threading.Event() + loop_responded_during_shutdown = False + + class BlockingLibrary(_TestLibrary): + def host_shutdown(self, server_id: int) -> bool: + nonlocal loop_responded_during_shutdown + loop.call_soon_threadsafe(loop_response.set) + loop_responded_during_shutdown = loop_response.wait(5) + return super().host_shutdown(server_id) + + library = BlockingLibrary() + library.allow_close = True + with patch("copilot._ffi_runtime_host._load_library", return_value=library): + host = FfiRuntimeHost("test-runtime", None) + host._server_id = 11 + host._connection_id = 21 + host._outbound_callback = object() + + client = CopilotClient(connection=RuntimeConnection.for_stdio(path="copilot")) + client._ffi_host = host + client._process = host.process + client._state = "connected" + + await getattr(client, method)() + + assert loop_responded_during_shutdown, "Native shutdown blocked the asyncio event loop" + assert library.shutdown.is_set(), "Client shutdown returned before native cleanup" + assert library.shutdown_calls == 1 + assert host._server_id == 0 + assert host._connection_id == 0 + assert host._outbound_callback is None + assert client._ffi_host is None + assert client._process is None + assert client._state == "disconnected" diff --git a/python/tests/test_installation_confirmation.py b/python/tests/test_installation_confirmation.py index 92db520c67..8056ca027b 100644 --- a/python/tests/test_installation_confirmation.py +++ b/python/tests/test_installation_confirmation.py @@ -2,6 +2,7 @@ import asyncio import os +import threading from typing import Any, cast import pytest @@ -83,6 +84,9 @@ async def close_peer_connection(self) -> None: await asyncio.sleep(0) async def close(self) -> None: + # Unblock both readers before closing either read descriptor (Windows CRT). + self.peer_process.stdin.close() + self.sdk_process.stdin.close() self.peer_process.terminate() self.sdk_process.terminate() await self.peer_rpc.stop() @@ -180,6 +184,50 @@ async def _wait_for(predicate, *, timeout: float = 5.0) -> None: await asyncio.sleep(0) +async def test_closes_both_writers_before_waiting_for_pipe_readers(monkeypatch) -> None: + loop = asyncio.get_running_loop() + reading = asyncio.Event() + sdk_process, peer_process = _pipe_process_pair() + reader_lock = threading.Lock() + original_readline = peer_process.stdout.readline + original_close = peer_process.stdout.close + + # Windows' CRT holds the descriptor lock across a blocking read and takes + # that same lock on close. Reproduce that boundary on every platform. + def locked_readline(): + with reader_lock: + loop.call_soon_threadsafe(reading.set) + return original_readline() + + def locked_close(): + with reader_lock: + original_close() + + monkeypatch.setattr(peer_process.stdout, "readline", locked_readline) + monkeypatch.setattr(peer_process.stdout, "close", locked_close) + monkeypatch.setattr(f"{__name__}._pipe_process_pair", lambda: (sdk_process, peer_process)) + harness = await _connect() + closing = None + try: + await asyncio.wait_for(reading.wait(), timeout=5) + closing = asyncio.create_task(asyncio.to_thread(lambda: asyncio.run(harness.close()))) + await asyncio.wait_for(asyncio.shield(closing), timeout=5) + for rpc in (harness.peer_rpc, harness.sdk_rpc): + assert rpc._read_thread is not None # noqa: SLF001 + assert not rpc._read_thread.is_alive() # noqa: SLF001 + for process in (sdk_process, peer_process): + assert process.stdin.closed + assert process.stdout.closed + finally: + # Release a deadlocked close before awaiting its cleanup on failure. + sdk_process.stdin.close() + peer_process.stdin.close() + if closing is not None: + await closing + else: + await harness.close() + + async def test_presents_typed_full_review_and_echoes_challenge_and_fingerprint() -> None: seen: list[tuple[InstallationConfirmationRequest, InstallationConfirmationContext]] = [] @@ -231,7 +279,7 @@ def handler( second = asyncio.create_task( harness.peer.request(402, "installations.confirm", _request("b")) ) - await _wait_for(lambda: seen == ["a", "b"]) + await _wait_for(lambda: sorted(seen) == ["a", "b"]) with pytest.raises(JsonRpcError, match="No GitHub token provider"): await harness.peer.request( diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0b5c85e472..1a566a7433 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -44,6 +44,10 @@ test-support = [] all-features = true rustdoc-args = ["--cfg", "docsrs"] +[lints.clippy] +# Threshold lives in clippy.toml; consumers run on 2 MiB Tokio worker stacks. +large_futures = "warn" + [dependencies] async-trait = "0.1" indexmap = { version = "2", features = ["serde"] } diff --git a/rust/clippy.toml b/rust/clippy.toml index 22781c4721..a9912b0514 100644 --- a/rust/clippy.toml +++ b/rust/clippy.toml @@ -6,3 +6,7 @@ await-holding-invalid-types = [ disallowed-macros = [ { path = "tracing::instrument", reason = "tracing::instrument is error-prone. Use tracing::error_span! in the method body instead." }, ] + +# Matches the GitHub Copilot app, which vendors this crate and runs on 2 MiB +# Tokio worker stacks; enforced through `large_futures` in Cargo.toml. +future-size-threshold = 16384 diff --git a/rust/src/ahp_host/tests.rs b/rust/src/ahp_host/tests.rs index 278b2bcaf5..ce9117b45c 100644 --- a/rust/src/ahp_host/tests.rs +++ b/rust/src/ahp_host/tests.rs @@ -643,12 +643,18 @@ async fn disconnect_notifies_all_callbacks_even_when_they_panic() { async fn callback_panic_does_not_break_other_callbacks_or_rpc() { let (client, mut peer) = fixture(); let calls = Arc::new(AtomicUsize::new(0)); + let (first_tx, mut first_exits) = mpsc::unbounded_channel(); + // The second callback must not act as a completion barrier for the first. + let (unblock, gate) = std::sync::mpsc::channel::<()>(); + let gate = std::sync::Mutex::new(gate); let pending = start( &client, local_options().with_on_exit({ let calls = calls.clone(); - move |_| { + move |exit| { + gate.lock().unwrap().recv().unwrap(); calls.fetch_add(1, Ordering::SeqCst); + first_tx.send(exit).unwrap(); panic!("test callback panic"); } }), @@ -665,6 +671,15 @@ async fn callback_panic_does_not_break_other_callbacks_or_rpc() { peer.exited(&first.host_id).await; peer.exited(&second.host_id).await; timeout(TIMEOUT, exits.recv()).await.unwrap().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 0); + unblock.send(()).unwrap(); + timeout(TIMEOUT, first_exits.recv()).await.unwrap().unwrap(); + assert!( + timeout(TIMEOUT, first_exits.recv()) + .await + .unwrap() + .is_none() + ); assert_eq!(calls.load(Ordering::SeqCst), 1); assert!(client.inner.ahp_host_callbacks.lock().is_empty()); let dispose = tokio::spawn(async move { second.dispose().await }); diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 4458396303..bbd4138099 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -742,3 +742,6 @@ mod tests { assert_eq!(TEST_SHUTDOWN_CALLS.load(Ordering::SeqCst), 1); } } + +#[cfg(test)] +mod shutdown_tests; diff --git a/rust/src/ffi/shutdown_tests.rs b/rust/src/ffi/shutdown_tests.rs new file mode 100644 index 0000000000..712040342f --- /dev/null +++ b/rust/src/ffi/shutdown_tests.rs @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#![cfg(test)] + +use std::sync::{Mutex, mpsc as std_mpsc}; +use std::time::Duration; + +use super::*; +use crate::Client; + +// The native function pointer cannot capture the per-test shutdown gate. +static SHUTDOWN_GATE: Mutex, std_mpsc::Receiver<()>)>> = + Mutex::new(None); + +unsafe extern "C" fn gated_host_shutdown(_server_id: u32) -> bool { + let gate = SHUTDOWN_GATE.lock().unwrap(); + let (started, release) = gate.as_ref().unwrap(); + started.send(()).unwrap(); + release.recv().unwrap(); + true +} + +unsafe extern "C" fn connection_close(_connection_id: u32) -> bool { + true +} + +unsafe extern "C" fn connection_write( + _connection_id: u32, + _bytes: *const u8, + _length: usize, +) -> bool { + true +} + +#[test] +fn async_stop_keeps_current_thread_executor_responsive_during_native_shutdown() { + let (started_tx, started_rx) = std_mpsc::sync_channel(1); + let (release_tx, release_rx) = std_mpsc::channel(); + let (progress_tx, progress_rx) = std_mpsc::channel(); + let (tick_tx, tick_rx) = tokio::sync::oneshot::channel(); + *SHUTDOWN_GATE.lock().unwrap() = Some((started_tx, release_rx)); + let worker = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let directory = tempfile::tempdir().unwrap(); + let client = Client::from_streams( + tokio::io::empty(), + tokio::io::sink(), + directory.path().to_path_buf(), + ) + .unwrap(); + *client.inner.ffi_host.lock() = Some(Arc::new(FfiShared { + host_shutdown: gated_host_shutdown, + connection_write, + connection_close, + server_id: AtomicU32::new(11), + connection_id: AtomicU32::new(0), + callback_state: AtomicPtr::new(std::ptr::null_mut()), + closed: AtomicBool::new(false), + operation_lock: parking_lot::Mutex::new(()), + library_path: PathBuf::from("test-runtime"), + })); + client.inner.rpc.force_close(); + let stop = tokio::spawn(async move { client.stop().await }); + tick_rx.await.unwrap(); + progress_tx.send(stop.is_finished()).unwrap(); + stop.await + .unwrap() + .expect_err("the RPC connection is closed") + }) + }); + + let started = started_rx.recv_timeout(Duration::from_secs(5)); + tick_tx.send(()).unwrap(); + let progress = progress_rx.recv_timeout(Duration::from_secs(5)); + release_tx.send(()).unwrap(); + let errors = worker.join().unwrap(); + *SHUTDOWN_GATE.lock().unwrap() = None; + + assert_eq!(started, Ok(()), "native shutdown did not start"); + assert_eq!( + progress, + Ok(false), + "native shutdown blocked the executor or stop completed before cleanup" + ); + assert_eq!(errors.0.len(), 1, "only the closed RPC should fail"); +} diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 5eab83bde2..22c8d0fe7a 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -188,6 +188,14 @@ pub mod rpc_methods { pub const MANAGEDSETTINGS_READ: &str = "managedSettings.read"; /// `managedSettings.clearCache` pub const MANAGEDSETTINGS_CLEARCACHE: &str = "managedSettings.clearCache"; + /// `managedSettings.resolve` + pub const MANAGEDSETTINGS_RESOLVE: &str = "managedSettings.resolve"; + /// `managedSettings.schema` + pub const MANAGEDSETTINGS_SCHEMA: &str = "managedSettings.schema"; + /// `managedSettings.validate` + pub const MANAGEDSETTINGS_VALIDATE: &str = "managedSettings.validate"; + /// `managedSettings.compose` + pub const MANAGEDSETTINGS_COMPOSE: &str = "managedSettings.compose"; /// `runtime.shutdown` pub const RUNTIME_SHUTDOWN: &str = "runtime.shutdown"; /// `sessionFs.setProvider` @@ -9706,7 +9714,7 @@ pub struct ManagedMcpServerConfig { pub url: String, } -/// Validated device-managed settings discovered before a session exists. +/// Lock state and provenance of one managed setting. /// ///
/// @@ -9716,16 +9724,103 @@ pub struct ManagedMcpServerConfig { ///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ManagedSettingsReadResult { - /// Discovery or validation error text when managed settings could not be read safely. +pub struct ManagedSettingMeta { + /// Whether users and repositories may choose a different value. `false` means policy locks the value. + pub overridable: bool, + /// Channel that supplied this scalar value, matching a `layers[].source`: `device`, `server`, or `policyHelper`. These scalar defaults select one winning channel, not a mixed source. Treat unknown values as additional channels; more may be added. + pub source: String, +} + +/// One candidate channel; absent settings represents a channel that delivered no document. +/// +///
+/// +/// **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 ManagedSettingsComposeLayer { + /// Candidate managed-settings document. Omit when the channel delivered none, as in resolve output. #[serde(skip_serializing_if = "Option::is_none")] - pub error_message: Option, - /// Validated, canonical managed-settings JSON. Omitted when no managed settings were discovered or when discovered settings failed validation. + pub settings: Option, + /// The channel whose candidate document is being supplied. + pub source: ManagedSettingsChannel, +} + +/// Candidate managed-settings documents to merge without applying them. +/// +///
+/// +/// **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 ManagedSettingsComposeRequest { + /// One entry per channel. `source` must be `device`, `server`, or `policyHelper`, each at most once (checked at runtime); order does not matter, because channel precedence is fixed. To preview documents from resolve output, map recognized source strings to ManagedSettingsChannel and copy their settings; generated resolve and compose layer types are distinct. Omitted settings means this channel delivered no document. Supplied documents must be valid within the preview limits; warnings are returned in diagnostics. Compose does not reproduce source-failure state or retained enforcement floors from resolve. + pub layers: Vec, +} + +/// One validation finding for a managed-settings document. +/// +///
+/// +/// **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 ManagedSettingsDiagnostic { + /// Human-readable description of the finding. + pub message: String, + /// Dot-separated path of the offending setting, such as `autoTier.overridable`. Empty for the document as a whole. + pub path: String, + /// Whether the finding rejects the document. + pub severity: ManagedSettingsDiagnosticSeverity, +} + +/// One managed-settings channel and the document it delivered. +/// +///
+/// +/// **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 ManagedSettingsLayer { + /// Validated managed-settings document this channel delivered. Absent when the channel delivered none. #[serde(skip_serializing_if = "Option::is_none")] - pub settings_json: Option, + pub settings: Option, + /// Channel identifier: `device` (MDM, plist, registry, or managed file), `server` (account or organization policy), or `policyHelper` (session-local helper output, supported by compose). Treat unknown output values as additional channels; more may be added. + pub source: String, } -/// 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. +/// Per-key lock state and provenance for `ManagedSettingsValues`, with the same field names. Producers emit each typed key in values and meta together; both outer objects are omitted when no typed key is set. +/// +///
+/// +/// **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 ManagedSettingsMeta { + /// Lock state and provenance of `values.autoTier`. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, + /// Lock state and provenance of `values.model`. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. /// ///
/// @@ -9765,6 +9860,174 @@ pub struct ManagedSettingsResolvedData { pub source: ManagedSettingsResolvedSource, } +/// Typed effective values of managed settings. Each field mirrors the managed-settings schema key of the same name; more keys are added as they are typed. +/// +///
+/// +/// **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 ManagedSettingsValues { + /// Managed Auto routing preference, used when the selected model is `auto`. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_tier: Option, + /// Managed default model identifier, as configured. New sessions start with it; it can name a model the account cannot use, so hosts match it against the listed models. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns. +/// +///
+/// +/// **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 ManagedSettingsComposeResult { + /// Warnings about ignored content, with paths prefixed by the channel name. + pub diagnostics: Vec, + /// Only the supplied channels, strongest first, with canonical documents. Empty canonical documents are represented as absent settings, as in live resolution. + pub layers: Vec, + /// Per-key lock state and provenance for `values`. + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Effective managed settings, in the same shape as `session.managedSettings.get`. + pub resolved: ManagedSettingsResolvedData, + /// Typed effective values, as in `managedSettings.resolve`. + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, +} + +/// Validated device-managed settings discovered before a session exists. +/// +///
+/// +/// **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 ManagedSettingsReadResult { + /// Discovery or validation error text when managed settings could not be read safely. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + /// Validated, canonical managed-settings JSON. Omitted when no managed settings were discovered or when discovered settings failed validation. + #[serde(skip_serializing_if = "Option::is_none")] + pub settings_json: Option, +} + +/// Optional opaque account selection or GitHub token whose managed settings are resolved. +/// +///
+/// +/// **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 ManagedSettingsResolveRequest { + /// Embedding client identity for server policy requests, as in session creation. Omit for the CLI identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_name: Option, + /// GitHub token to resolve instead of the current account. The call fails when the token cannot be resolved. + #[serde(skip_serializing_if = "Option::is_none")] + pub git_hub_token: Option, + /// Opaque account identifier returned by `account.getAllUsers`. When omitted, the current account is used, or device policy only when no account is signed in. + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_id: Option, +} + +/// Effective enterprise managed settings for an account, resolved without 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 ManagedSettingsResolveResult { + /// Printable opaque identity of the account the settings were resolved for, suitable for comparison and storage, not an account selectionId. Absent when no account was available, in which case only device policy is reported. + #[serde(skip_serializing_if = "Option::is_none")] + pub account: Option, + /// Warnings about unavailable policy sources or a failed refresh served from cache. A cached response is not proof of a successful live fetch; `resolved.failClosed` separately describes enforcement. + pub diagnostics: Vec, + /// Each managed-settings channel consulted, strongest first, with the validated document it delivered before merging. `resolved.settings` is the merged result. More channels may be added over time. + pub layers: Vec, + /// Per-key lock state and provenance for the entries in `values`, using the same key names. + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Effective managed settings from the device and account (server) channels, in the same shape as `session.managedSettings.get`, excluding session-local injection. + pub resolved: ManagedSettingsResolvedData, + /// Typed effective values of managed settings, keyed like the managed-settings schema and already resolved across channels, with the `{ "overridable": ... }` wrapper removed. Present when policy sets at least one typed key. Keys not typed here are available in `resolved.settings`. + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, +} + +/// The authoring JSON schema for managed settings recognized by this runtime. +/// +///
+/// +/// **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 ManagedSettingsSchemaResult { + /// Version of the runtime that owns this schema. + pub runtime_version: String, + /// JSON schema (draft 2020-12) with descriptive shared `x-composition` annotations, not a complete runtime composition contract. Model, effortLevel, and contextTier remain coupled; use `managedSettings.compose` for the runtime's effective result. + pub schema: serde_json::Value, +} + +/// A candidate managed-settings document to validate without applying it. +/// +///
+/// +/// **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 ManagedSettingsValidateRequest { + /// The document to validate: a JSON object, or a string containing the document's JSON text. Preview documents are limited to 1 MiB and 64 levels of nesting, a stricter resource limit than delivered-policy parsing; violations are returned as diagnostics. + pub content: serde_json::Value, + /// Channel the document is meant for (`device`, `server`, or `policyHelper`). Some keys are only honored in some channels; for example, a `policyHelper` registration is ignored in policy-helper output. When omitted, no channel-specific checks run. + #[serde(skip_serializing_if = "Option::is_none")] + pub layer: Option, +} + +/// Result of validating a managed-settings document. +/// +///
+/// +/// **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 ManagedSettingsValidateResult { + /// Errors that reject the document and warnings about content the runtime ignores. + pub diagnostics: Vec, + /// Canonical form of the document the runtime would apply, with unrecognized keys removed. Absent when the document is invalid. + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, + /// Whether the runtime would accept the document within the preview resource limits. Always equals whether `settings` is present. An invalid document is rejected as a whole. + pub valid: bool, +} + /// Result of registering a new marketplace. /// ///
@@ -31641,7 +31904,7 @@ pub struct SessionManagedSettingsGetParams { pub session_id: SessionId, } -/// 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. +/// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. /// ///
/// @@ -38941,6 +39204,53 @@ pub enum SessionLogLevel { Unknown, } +/// A channel accepted by managedSettings.compose. +/// +///
+/// +/// **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, PartialEq, Eq, Serialize, Deserialize)] +pub enum ManagedSettingsChannel { + /// Device policy, the strongest channel. + #[serde(rename = "device")] + Device, + /// Account or organization policy. + #[serde(rename = "server")] + Server, + /// Session-local helper output, the weakest channel. + #[serde(rename = "policyHelper")] + PolicyHelper, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Severity of a managed-settings validation finding. +/// +///
+/// +/// **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, PartialEq, Eq, Serialize, Deserialize)] +pub enum ManagedSettingsDiagnosticSeverity { + /// The runtime rejects the document. + #[serde(rename = "error")] + Error, + /// The runtime accepts the document but ignores the flagged content. + #[serde(rename = "warning")] + Warning, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Allowed values for the `McpAppsHostContextDetailsAvailableDisplayMode` enumeration. /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 3c810588ab..f6ac0d769e 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -1352,7 +1352,7 @@ pub struct ClientRpcManagedSettings<'a> { } impl<'a> ClientRpcManagedSettings<'a> { - /// Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session. + /// Discovers device-managed settings from production MDM and managed-file sources, validates them against the runtime-owned managed-settings schema, and returns the canonical JSON without requiring a session. `managedSettings.resolve` returns the same device settings together with the account's server policy. /// /// Wire method: `managedSettings.read`. /// @@ -1376,7 +1376,7 @@ impl<'a> ClientRpcManagedSettings<'a> { Ok(serde_json::from_value(_value)?) } - /// Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing "sync account policy" should start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed. + /// Force-refreshes enterprise managed settings for every account: wipes the persistent server-policy cache (the whole `/managed-settings` directory) and drops this runtime process's in-memory retained server policy. It does not itself fetch policy — the effect is that the next time a session resolves managed settings for an account, that resolution re-fetches the account's org policy from the network instead of serving a cached response. Note that `managedSettings.read` returns only device/MDM settings and never triggers the account server-policy fetch, so a host implementing "sync account policy" should call `managedSettings.resolve` or start a fresh session resolution rather than treat a subsequent `managedSettings.read` as the refreshed org policy. Mirrors the invalidation a sign-out performs, broadened from the one signing-out account to all of them; device/MDM layers describe the machine, not the account, and are left untouched. Rejects if the on-disk cache cannot be removed. /// /// Wire method: `managedSettings.clearCache`. /// @@ -1395,6 +1395,147 @@ impl<'a> ClientRpcManagedSettings<'a> { .await?; Ok(()) } + + /// Resolves the effective enterprise managed settings without a session, from the device channel and, when an account is available, the account's server policy through the same per-account cache sessions use. A cached server policy less than an hour old is used without a fetch; otherwise the policy is fetched, and when the fetch fails a cached policy up to 24 hours old is used instead, unless `forceRemoteSettingsRefresh` requires a live fetch. With no account requested or signed in, it reports device policy only; signing out removes the account's cached policy. It can fetch server policy over the network when the cache is stale, so call it off latency-critical paths such as startup rather than before listing models. The policy helper is not run. `layers` lists each channel's document before merging, and `values` and `meta` carry typed effective values and their lock state for the keys typed so far. + /// + /// Wire method: `managedSettings.resolve`. + /// + /// # Returns + /// + /// Effective enterprise managed settings for an account, resolved without 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 resolve(&self) -> Result { + let wire_params = serde_json::json!({}); + let _value = self + .client + .call(rpc_methods::MANAGEDSETTINGS_RESOLVE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Resolves the effective enterprise managed settings without a session, from the device channel and, when an account is available, the account's server policy through the same per-account cache sessions use. A cached server policy less than an hour old is used without a fetch; otherwise the policy is fetched, and when the fetch fails a cached policy up to 24 hours old is used instead, unless `forceRemoteSettingsRefresh` requires a live fetch. With no account requested or signed in, it reports device policy only; signing out removes the account's cached policy. It can fetch server policy over the network when the cache is stale, so call it off latency-critical paths such as startup rather than before listing models. The policy helper is not run. `layers` lists each channel's document before merging, and `values` and `meta` carry typed effective values and their lock state for the keys typed so far. + /// + /// Wire method: `managedSettings.resolve`. + /// + /// # Parameters + /// + /// * `params` - Optional opaque account selection or GitHub token whose managed settings are resolved. + /// + /// # Returns + /// + /// Effective enterprise managed settings for an account, resolved without 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 resolve_with_params( + &self, + params: ManagedSettingsResolveRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::MANAGEDSETTINGS_RESOLVE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Returns the managed-settings authoring JSON schema with descriptive `x-composition` annotations aligned with the shared settings-engine vocabulary. These annotations are not a complete runtime composition contract: model, effortLevel, and contextTier remain coupled. Use `managedSettings.compose` for the runtime's effective result. Performs no I/O. + /// + /// Wire method: `managedSettings.schema`. + /// + /// # Returns + /// + /// The authoring JSON schema for managed settings recognized by this runtime. + /// + ///
+ /// + /// **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 schema(&self) -> Result { + let wire_params = serde_json::json!({}); + let _value = self + .client + .call(rpc_methods::MANAGEDSETTINGS_SCHEMA, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Validates a candidate managed-settings document the way the runtime validates delivered policy, without applying it. Reports errors that would reject the document, warnings for content the runtime ignores, and the canonical document it would apply. Document text nested more than 64 levels deep is rejected. Performs no I/O. + /// + /// Wire method: `managedSettings.validate`. + /// + /// # Parameters + /// + /// * `params` - A candidate managed-settings document to validate without applying it. + /// + /// # Returns + /// + /// Result of validating a managed-settings document. + /// + ///
+ /// + /// **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 validate( + &self, + params: ManagedSettingsValidateRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::MANAGEDSETTINGS_VALIDATE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Merges candidate managed-settings documents for the device, server, and policy-helper channels into the effective settings the runtime would enforce on this host, using the same precedence and composition rules as live resolution, without applying them. Like live resolution, a server's advisory sandbox force-enable is declined on a host that cannot run the sandbox. Does not fetch policy or read policy files, but may perform blocking OS or subprocess probes for sandbox support. Preview documents are limited to 1 MiB and 64 levels of nesting. + /// + /// Wire method: `managedSettings.compose`. + /// + /// # Parameters + /// + /// * `params` - Candidate managed-settings documents to merge without applying them. + /// + /// # Returns + /// + /// The effective managed settings the runtime would enforce for the given documents, in the same shape `managedSettings.resolve` returns. + /// + ///
+ /// + /// **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 compose( + &self, + params: ManagedSettingsComposeRequest, + ) -> Result { + let wire_params = serde_json::to_value(params)?; + let _value = self + .client + .call(rpc_methods::MANAGEDSETTINGS_COMPOSE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } } /// `mcp.*` RPCs. @@ -7452,7 +7593,7 @@ impl<'a> SessionRpcManagedSettings<'a> { /// /// # Returns /// - /// 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. + /// Effective enterprise managed settings and contributing channels. Session events report applied policy; sessionless resolve reports an account/device snapshot, and compose reports a non-applying preview of candidate documents. Device values take precedence over server values, then the policy helper, per ordinary key, while permissions compose restrictively. Session-local SDK-client policy is included only in session results. Marked experimental while the managed-settings surface stabilizes. /// ///
/// diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 2d7992d30b..613d99329e 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -7415,7 +7415,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, 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. +/// Session event "session.managed_settings_resolved". Effective enterprise managed settings applied to the session and their contributing channels. Emitted whenever managed policy is applied or reapplied, including session start, resume, and account switch. This ephemeral live snapshot is delivered to subscribers but not persisted to the session event log; initial resolution occurs before session.start. /// ///
/// diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5c59b8c53b..da05be1482 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3105,8 +3105,11 @@ impl Client { self.inner.rpc.force_close(); #[cfg(feature = "in-process")] { - if let Some(host) = self.inner.ffi_host.lock().take() { - host.close(); + let host = self.inner.ffi_host.lock().take(); + if let Some(host) = host + && let Err(error) = tokio::task::spawn_blocking(move || host.close()).await + { + errors.push(Error::new(ErrorKind::Io, error)); } } diff --git a/rust/src/session.rs b/rust/src/session.rs index 193203c3cb..82d1e2cc89 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -2295,16 +2295,15 @@ impl PreparedSession { event_tx, shutdown, } = self; + // The startup state machines exceed 16 KiB; boxing them keeps this + // future, and every public caller awaiting it, small enough for + // 2 MiB worker stacks and `clippy::large_futures`. match kind { PreparedKind::Create(config) => { - client - .start_prepared_create(*config, event_tx, shutdown) - .await + Box::pin(client.start_prepared_create(*config, event_tx, shutdown)).await } PreparedKind::Resume(config) => { - client - .start_prepared_resume(*config, event_tx, shutdown) - .await + Box::pin(client.start_prepared_resume(*config, event_tx, shutdown)).await } } } diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs index b0fd4c5111..59b17a4290 100644 --- a/rust/tests/e2e/canvas.rs +++ b/rust/tests/e2e/canvas.rs @@ -556,6 +556,15 @@ async fn resumed_canvas_reattaches_and_routes_all_callbacks() { }) .await .expect("open canvas"); + // RPC completion can precede the event that updates the snapshot cache. + let mut events = session.subscribe(); + tokio::time::timeout(Duration::from_secs(10), async { + while session.open_canvases().is_empty() { + events.recv().await.expect("opened canvas event"); + } + }) + .await + .expect("opened canvas snapshot"); let snapshots = session.open_canvases(); assert_eq!(snapshots.len(), 1); @@ -642,6 +651,13 @@ async fn resumed_canvas_reattaches_and_routes_all_callbacks() { "close:counter-resume" ] ); + tokio::time::timeout(Duration::from_secs(10), async { + while !resumed.open_canvases().is_empty() { + events.recv().await.expect("closed canvas event"); + } + }) + .await + .expect("closed canvas snapshot"); assert!(resumed.open_canvases().is_empty()); resumed diff --git a/rust/tests/e2e/runtime_host_support.rs b/rust/tests/e2e/runtime_host_support.rs index b8d7909bc3..e38ed6ff70 100644 --- a/rust/tests/e2e/runtime_host_support.rs +++ b/rust/tests/e2e/runtime_host_support.rs @@ -176,10 +176,15 @@ pub async fn exit(exits: &Arc>>) -> AhpHostExit { .await } -pub async fn deadline(future: impl Future) -> T { - tokio::time::timeout(DEADLINE, future) - .await - .expect("AHP operation deadline") +/// Boxes eagerly so callers do not embed large wrapped futures, such as +/// `WebSocketTransport::connect`, whose size depends on consumer TLS features. +pub fn deadline(future: impl Future) -> impl Future { + let future = Box::pin(future); + async move { + tokio::time::timeout(DEADLINE, future) + .await + .expect("AHP operation deadline") + } } pub struct Ahp { diff --git a/rust/tests/e2e/subagent_hooks.rs b/rust/tests/e2e/subagent_hooks.rs index 32fdc71d02..f106223e5a 100644 --- a/rust/tests/e2e/subagent_hooks.rs +++ b/rust/tests/e2e/subagent_hooks.rs @@ -35,6 +35,8 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls let hook_log = Arc::new(Mutex::new(Vec::::new())); let request_log = Arc::new(RecordingRequestHandler::default()); + let waiting_text = "I've launched an explore agent to read subagent-test.txt. Waiting for it to complete..."; + let final_text = "The explore agent successfully read the file. The contents of **subagent-test.txt** are:\n\n```\nHello from subagent test!\n```"; let (parent_reply, parent_reply_observed) = watch::channel(false); let client = ctx @@ -59,14 +61,17 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls session.subscribe(), "parent waiting reply and subagent result followed by session.idle", |event| { + if !event.agent_id.as_deref().is_none_or(str::is_empty) { + return false; + } if event.parsed_type() == SessionEventType::AssistantMessage { let content = assistant_message_content(event); - if content.contains("Waiting for it to complete...") { + if content == waiting_text { parent_reply .send(true) .expect("sub-agent hook should await the parent reply"); } - if content.contains("Hello from subagent test!") { + if content == final_text { saw_final_response.set(true); } } @@ -83,6 +88,15 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls completion, ); send_result.expect("send"); + let history = session.get_events().await.expect("get durable history"); + let replies: Vec<_> = history.iter() + .filter(|event| event.agent_id.as_deref().is_none_or(str::is_empty) + && event.parsed_type() == SessionEventType::AssistantMessage) + .map(assistant_message_content) + .filter(|content| *content == waiting_text || *content == final_text) + .collect(); + assert_eq!(replies, [waiting_text, final_text], + "durable history must contain the waiting reply before the final reply"); let log = hook_log.lock().clone(); diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index fbbb3895d3..4f148a129f 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -4278,6 +4278,12 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc } lines.push(`\traw, err := ${clientRef}.Request(ctx, "${method.rpcMethod}", req)`); } else { + if (method.rpcMethod === "managedSettings.resolve") { + // A typed nil inside Request's any argument marshals as null, not omitted params. + lines.push(`\tif ${paramsRef} == nil {`); + lines.push(`\t\t${paramsRef} = &${paramsType}{}`); + lines.push(`\t}`); + } const arg = hasParams ? paramsRef : "nil"; lines.push(`\traw, err := ${clientRef}.Request(ctx, "${method.rpcMethod}", ${arg})`); } From 2cc3231a8bb433e6be84804d67b19b2880f901d8 Mon Sep 17 00:00:00 2001 From: "copilot-cli-release-app[bot]" Date: Thu, 1 Oct 2026 06:17:09 +0000 Subject: [PATCH 2/5] Update SDK snapshot for Copilot CLI 1.0.91-1 --- .../workflows/block-remove-before-merge.yml | 24 +- dotnet/src/Generated/Rpc.cs | 4 +- dotnet/test/ConnectionTokenTests.cs | 13 +- .../MultiClientCommandsElicitationE2ETests.cs | 6 +- dotnet/test/E2E/MultiClientE2ETests.cs | 5 +- dotnet/test/E2E/TelemetryExportE2ETests.cs | 3 +- dotnet/test/Harness/ReplayProxy.cs | 58 ++-- dotnet/test/Unit/E2ETestFixtureTests.cs | 26 ++ dotnet/test/Unit/JsonRpcTests.cs | 15 + go/rpc/zrpc.go | 20 +- .../generated/rpc/SandboxHostCapability.java | 4 +- nodejs/package.json | 2 +- nodejs/src/cliVersion.ts | 2 +- nodejs/src/client.ts | 74 +++- nodejs/src/generated/rpc.ts | 4 +- nodejs/test/client.test.ts | 317 +++++++++++++++++- nodejs/test/e2e/client.e2e.test.ts | 64 +++- .../test/e2e/pending_work_resume.e2e.test.ts | 93 ++--- nodejs/test/e2e/rpc_server.e2e.test.ts | 10 +- nodejs/test/e2e/workflow.e2e.test.ts | 2 +- python/copilot/generated/rpc.py | 12 +- python/e2e/test_session_e2e.py | 12 +- rust/src/generated/api_types.rs | 6 +- rust/tests/e2e/canvas.rs | 8 +- rust/tests/e2e/support.rs | 20 +- 25 files changed, 667 insertions(+), 137 deletions(-) diff --git a/.github/workflows/block-remove-before-merge.yml b/.github/workflows/block-remove-before-merge.yml index bc57bbb5d7..443eea9f7c 100644 --- a/.github/workflows/block-remove-before-merge.yml +++ b/.github/workflows/block-remove-before-merge.yml @@ -8,7 +8,7 @@ on: merge_group: permissions: - pull-requests: read + contents: read jobs: check-paths: @@ -16,19 +16,25 @@ jobs: if: github.event_name == 'pull_request' && github.base_ref == 'main' runs-on: ubuntu-latest steps: + - uses: actions/checkout@v7 + timeout-minutes: 4 + with: + fetch-depth: 2 - name: Check for remove-before-merge paths in PR - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} run: | - FILES=$(gh api repos/$REPO/pulls/$PR_NUMBER/files --paginate --jq '.[].filename') - BLOCKED=$(echo "$FILES" | grep -E '(^|/)[-a-zA-Z0-9_]+-remove-before-merge(/|$)' || true) - if [ -n "$BLOCKED" ]; then + # pull_request checks out the merge commit; its first parent is the base. + git rev-parse --verify HEAD^2 >/dev/null 2>&1 || { + echo "::error::Expected a pull request merge commit." + exit 1 + } + FILES_FILE=$(mktemp) + trap 'rm -f "$FILES_FILE"' EXIT + git diff --name-only -z HEAD^1 HEAD > "$FILES_FILE" + if grep -zqE '(^|/)[-a-zA-Z0-9_]+-remove-before-merge(/|$)' "$FILES_FILE"; then echo "::error::This PR contains files under a 'remove-before-merge' directory. Remove them before merging." echo "" echo "Offending paths:" - echo "$BLOCKED" + grep -zE '(^|/)[-a-zA-Z0-9_]+-remove-before-merge(/|$)' "$FILES_FILE" | tr '\0' '\n' exit 1 fi echo "No remove-before-merge paths found. ✅" diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index c95a8e6353..3f97448751 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -916,11 +916,11 @@ public sealed class BuiltInModelCatalog public IList Models { get => field ??= []; set; } } -/// Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`. +/// Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`, except `filesystem_enumeration`, whose absence degrades sandboxed PowerShell instead of failing it. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class SandboxHostCapability { - /// The policy feature, as an extensible string: ignore names you do not recognize. Known values: `network` (sandboxed commands can reach the network; on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns), `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`), `denied_paths` (native enforcement of `filesystem.deniedPaths`), and `shell` (shell commands inside the sandbox; on Windows this needs Process Security Environment 1.1 filesystem enumeration support). + /// The policy feature, as an extensible string: ignore names you do not recognize. Known values: `network` (sandboxed commands can reach the network; on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns), `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`), `denied_paths` (native enforcement of `filesystem.deniedPaths`), `shell` (shell commands inside the sandbox), and `filesystem_enumeration` (enumerate-only filesystem grants; on Windows this needs Process Security Environment 1.1 filesystem enumeration support, and without it sandboxed PowerShell still runs but cannot resolve its current location; other platforms always report it). [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; diff --git a/dotnet/test/ConnectionTokenTests.cs b/dotnet/test/ConnectionTokenTests.cs index 3192bada6b..678304dfa0 100644 --- a/dotnet/test/ConnectionTokenTests.cs +++ b/dotnet/test/ConnectionTokenTests.cs @@ -31,12 +31,19 @@ public async Task InitializeAsync() public async Task DisposeAsync() { + var errors = new List(); if (GoodClient is not null) { - await GoodClient.ForceStopAsync(); + try { await GoodClient.ForceStopAsync(); } + catch (Exception ex) { errors.Add(ex); } } - - await Ctx.DisposeAsync(); + if (Ctx is not null) + { + try { await Ctx.DisposeAsync(); } + catch (Exception ex) { errors.Add(ex); } + } + if (errors.Count == 1) throw errors[0]; + if (errors.Count > 1) throw new AggregateException(errors); } } diff --git a/dotnet/test/E2E/MultiClientCommandsElicitationE2ETests.cs b/dotnet/test/E2E/MultiClientCommandsElicitationE2ETests.cs index d869dc8164..eb2810f8a9 100644 --- a/dotnet/test/E2E/MultiClientCommandsElicitationE2ETests.cs +++ b/dotnet/test/E2E/MultiClientCommandsElicitationE2ETests.cs @@ -30,7 +30,10 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - await Ctx.DisposeAsync(); + if (Ctx is not null) + { + await Ctx.DisposeAsync(); + } } } @@ -259,4 +262,3 @@ public async Task Capabilities_Changed_Fires_When_Elicitation_Provider_Disconnec "After elicitation provider disconnects, capability should be removed"); } } - diff --git a/dotnet/test/E2E/MultiClientE2ETests.cs b/dotnet/test/E2E/MultiClientE2ETests.cs index 7597aa580b..082e1eeff7 100644 --- a/dotnet/test/E2E/MultiClientE2ETests.cs +++ b/dotnet/test/E2E/MultiClientE2ETests.cs @@ -33,7 +33,10 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - await Ctx.DisposeAsync(); + if (Ctx is not null) + { + await Ctx.DisposeAsync(); + } } } diff --git a/dotnet/test/E2E/TelemetryExportE2ETests.cs b/dotnet/test/E2E/TelemetryExportE2ETests.cs index e7db9298df..4eb0f8ab7e 100644 --- a/dotnet/test/E2E/TelemetryExportE2ETests.cs +++ b/dotnet/test/E2E/TelemetryExportE2ETests.cs @@ -205,7 +205,8 @@ private static int GetStatusCode(JsonElement entry) private static JsonElement AssertSpanWithOperation(IEnumerable spans, string operationName) { var matchingSpan = spans.FirstOrDefault(span => GetStringAttribute(span, "gen_ai.operation.name") == operationName); - Assert.NotEqual(JsonValueKind.Undefined, matchingSpan.ValueKind); + Assert.True(matchingSpan.ValueKind != JsonValueKind.Undefined, + $"Missing {operationName} span. Exported operations: {string.Join(", ", spans.Select(span => GetStringAttribute(span, "gen_ai.operation.name") ?? ""))}"); return matchingSpan; } diff --git a/dotnet/test/Harness/ReplayProxy.cs b/dotnet/test/Harness/ReplayProxy.cs index 5e22bd7d8b..0f9bd1dc4c 100644 --- a/dotnet/test/Harness/ReplayProxy.cs +++ b/dotnet/test/Harness/ReplayProxy.cs @@ -27,38 +27,29 @@ public Task StartAsync() async Task StartCoreAsync() { - string filename; - string args; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - filename = "cmd.exe"; - args = "/c npm.cmd run start"; - - } - else - { - filename = "npm"; - args = "run start"; - } - + // Spawn the server directly so a stalled npm/cmd wrapper cannot strand startup or cleanup. var startInfo = new ProcessStartInfo { - FileName = filename, + FileName = "node", WorkingDirectory = Path.Join(FindRepoRoot(), "test", "harness"), - Arguments = args, + Arguments = "--import tsx server.ts", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; - _process = new Process { StartInfo = startInfo }; + var process = new Process { StartInfo = startInfo }; var tcs = new TaskCompletionSource(); var errorOutput = new StringBuilder(); + var startupTimer = Stopwatch.StartNew(); + string CapturedErrors() + { + lock (errorOutput) return errorOutput.ToString(); + } - _process.OutputDataReceived += (_, e) => + process.OutputDataReceived += (_, e) => { if (e.Data == null) return; var match = Regex.Match(e.Data, @"Listening: (?http://[^\s]+)\s+(?\{.*\})$"); @@ -98,28 +89,39 @@ async Task StartCoreAsync() tcs.TrySetResult(match.Groups["url"].Value); }; - _process.ErrorDataReceived += (_, e) => + process.ErrorDataReceived += (_, e) => { if (e.Data == null) return; - errorOutput.AppendLine(e.Data); + lock (errorOutput) errorOutput.AppendLine(e.Data); Console.Error.WriteLine(e.Data); }; - _process.Start(); - _process.BeginOutputReadLine(); - _process.BeginErrorReadLine(); - _ = _process.WaitForExitAsync().ContinueWith(_ => + try + { + process.Start(); + } + catch + { + process.Dispose(); + throw; + } + _process = process; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + _ = process.WaitForExitAsync().ContinueWith(_ => { - if (_process?.ExitCode is int exitCode && exitCode != 0) + if (!tcs.Task.IsCompleted) { - tcs.TrySetException(new Exception($"Proxy exited with code {_process.ExitCode}: {errorOutput}")); + tcs.TrySetException(new Exception($"Proxy exited before listening with code {process.ExitCode}: {CapturedErrors()}")); } }); // Use longer timeout on Windows due to slower process startup var timeoutSeconds = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? 30 : 10; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); - cts.Token.Register(() => tcs.TrySetException(new TimeoutException("Timeout waiting for proxy"))); + cts.Token.Register(() => tcs.TrySetException(new TimeoutException( + $"Timeout waiting for proxy after {startupTimer.ElapsedMilliseconds}ms " + + $"(exited: {process.HasExited}; stderr: {CapturedErrors()})"))); return await tcs.Task; } diff --git a/dotnet/test/Unit/E2ETestFixtureTests.cs b/dotnet/test/Unit/E2ETestFixtureTests.cs index af56bcd3f3..e2d7cd0723 100644 --- a/dotnet/test/Unit/E2ETestFixtureTests.cs +++ b/dotnet/test/Unit/E2ETestFixtureTests.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Reflection; +using GitHub.Copilot.Test.E2E; using GitHub.Copilot.Test.Harness; using Xunit; @@ -116,4 +117,29 @@ public void Shared_Client_Preserves_Tcp_Connection_For_OutOfProcess_Tests() Assert.Equal(E2ETestFixture.SharedTcpConnectionToken, connection.ConnectionToken); } + + [Fact] + public async Task Dispose_Derived_Fixtures_Without_Initialized_Context_Does_Not_Throw() + { + await new MultiClientTestFixture().DisposeAsync(); + await new MultiClientCommandsElicitationFixture().DisposeAsync(); + await new ConnectionTokenTestFixture().DisposeAsync(); + } + + [Fact] + public async Task Replay_Proxy_Starts_With_Connect_Metadata_And_Stops() + { + var proxy = new ReplayProxy(); + try + { + var url = await proxy.StartAsync(); + Assert.StartsWith("http://", url); + Assert.False(string.IsNullOrWhiteSpace(proxy.ConnectProxyUrl)); + Assert.False(string.IsNullOrWhiteSpace(proxy.CaFilePath)); + } + finally + { + await proxy.StopAsync(skipWritingCache: true); + } + } } diff --git a/dotnet/test/Unit/JsonRpcTests.cs b/dotnet/test/Unit/JsonRpcTests.cs index c83ecda996..71189e4c59 100644 --- a/dotnet/test/Unit/JsonRpcTests.cs +++ b/dotnet/test/Unit/JsonRpcTests.cs @@ -126,6 +126,21 @@ public async Task JsonRpc_Cancels_Incoming_Handler_Token_When_Connection_Dispose await WaitForCancellationAsync(handlerToken); } + [Fact] + public async Task JsonRpc_Awaits_Declared_Task_Handler_With_Generic_Runtime_Task() + { + using var pair = JsonRpcReflectionPair.Create(); + pair.Server.SetLocalRpcMethod( + "genericRuntimeTask", + (Func)(_ => Task.FromResult(1)), + singleObjectParam: true); + pair.StartListening(); + + Assert.Null(await pair.Client.InvokeAsync( + "genericRuntimeTask", + [new SingleObjectRequest { Value = "value" }])); + } + [Fact] public async Task JsonRpc_Dispose_Completes_Cleanup_When_Cancellation_Callback_Throws() { diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 2d8c190a1e..2133117a3a 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -13378,7 +13378,9 @@ type SandboxGrantPathForRequestResult struct { } // Whether this host can run one sandbox policy feature. A session whose effective policy -// uses an unsupported feature fails each sandboxed command with `reason`. +// uses an unsupported feature fails each sandboxed command with `reason`, except +// `filesystem_enumeration`, whose absence degrades sandboxed PowerShell instead of failing +// it. // Experimental: SandboxHostCapability is part of an experimental API and may change or be // removed. type SandboxHostCapability struct { @@ -13388,9 +13390,11 @@ type SandboxHostCapability struct { // `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same // tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback // support, and a policy that uses it must also set `network.allowLocalNetwork`), - // `denied_paths` (native enforcement of `filesystem.deniedPaths`), and `shell` (shell - // commands inside the sandbox; on Windows this needs Process Security Environment 1.1 - // filesystem enumeration support). + // `denied_paths` (native enforcement of `filesystem.deniedPaths`), `shell` (shell commands + // inside the sandbox), and `filesystem_enumeration` (enumerate-only filesystem grants; on + // Windows this needs Process Security Environment 1.1 filesystem enumeration support, and + // without it sandboxed PowerShell still runs but cannot resolve its current location; other + // platforms always report it). Name string `json:"name"` // Human-readable reason and remedy when the feature is unsupported, such as a package to // install or an OS update. Present only when `supported` is false. @@ -13411,8 +13415,12 @@ type SandboxHostCapability struct { // proxy only together with private-network access. `denied_paths` — native enforcement of // `filesystem.deniedPaths`; on Windows this needs a version whose sandbox contract reports // denied-path support. `shell` — shell commands inside the sandbox: bash on macOS and -// Linux, PowerShell on Windows; on Windows this needs a version with Process Security -// Environment 1.1 filesystem enumeration support. +// Linux, PowerShell on Windows. `filesystem_enumeration` — enumerate-only filesystem +// grants, which PowerShell's drive roots use on Windows; this needs a version with Process +// Security Environment 1.1 filesystem enumeration support. Without it, sandboxed PowerShell +// still runs, but `Get-Location` may report the drive root, `Set-Location` may fail, and +// relative paths may resolve against the drive root; the session also receives a +// `session.warning` with `warningType` `sandbox`. Other platforms always report it. // Experimental: SandboxHostCapabilityName is part of an experimental API and may change or // be removed. type SandboxHostCapabilityName string diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxHostCapability.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxHostCapability.java index 000628e137..e3efc94513 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxHostCapability.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxHostCapability.java @@ -13,7 +13,7 @@ import javax.annotation.processing.Generated; /** - * Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`. + * Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`, except `filesystem_enumeration`, whose absence degrades sandboxed PowerShell instead of failing it. * * @since 1.0.0 */ @@ -21,7 +21,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record SandboxHostCapability( - /** The policy feature, as an extensible string: ignore names you do not recognize. Known values: `network` (sandboxed commands can reach the network; on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns), `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`), `denied_paths` (native enforcement of `filesystem.deniedPaths`), and `shell` (shell commands inside the sandbox; on Windows this needs Process Security Environment 1.1 filesystem enumeration support). */ + /** The policy feature, as an extensible string: ignore names you do not recognize. Known values: `network` (sandboxed commands can reach the network; on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns), `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`), `denied_paths` (native enforcement of `filesystem.deniedPaths`), `shell` (shell commands inside the sandbox), and `filesystem_enumeration` (enumerate-only filesystem grants; on Windows this needs Process Security Environment 1.1 filesystem enumeration support, and without it sandboxed PowerShell still runs but cannot resolve its current location; other platforms always report it). */ @JsonProperty("name") String name, /** Whether this host can run the feature. */ @JsonProperty("supported") Boolean supported, diff --git a/nodejs/package.json b/nodejs/package.json index a6f5d21702..7f6cc0cacb 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/github/copilot-sdk.git" }, "version": "0.0.0-dev", - "copilotCliVersion": "1.0.91-0", + "copilotCliVersion": "1.0.91-1", "description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC", "main": "./dist/cjs/index.js", "types": "./dist/index.d.ts", diff --git a/nodejs/src/cliVersion.ts b/nodejs/src/cliVersion.ts index 61f921ab58..c923a8e653 100644 --- a/nodejs/src/cliVersion.ts +++ b/nodejs/src/cliVersion.ts @@ -1,3 +1,3 @@ -export const COPILOT_CLI_VERSION = "1.0.91-0"; +export const COPILOT_CLI_VERSION = "1.0.91-1"; export const COPILOT_CLI_USE_NPM_PACKAGE = false; diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index d53e98d1f8..27c9707e3f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -494,6 +494,7 @@ export class CopilotClient { private requestAdapter: ReturnType | null = null; private messageWriter: TeardownResilientStreamMessageWriter | null = null; private connectionClosed: boolean = false; + private connectionEpoch = 0; private socket: Socket | null = null; private runtimePort: number | null = null; private actualHost: string = "localhost"; @@ -1027,6 +1028,7 @@ export class CopilotClient { private async doStart(): Promise { this.forceStopping = false; + this.connectionEpoch++; this.connectionClosed = false; this.processTransportError = null; this.state = "connecting"; @@ -1077,6 +1079,9 @@ export class CopilotClient { await this.connection!.sendRequest("llmInference.setProvider", {}); } + if (this.connectionClosed) { + throw new Error("CLI server connection closed during startup"); + } this.state = "connected"; } catch (error) { const startupError = this.processTransportError ?? error; @@ -1233,10 +1238,24 @@ export class CopilotClient { this.socket = null; try { if (!socket.destroyed) { - await new Promise((resolve) => { - socket.once("close", () => resolve()); - socket.end(); - }); + let timeout: ReturnType | undefined; + try { + await new Promise((resolve) => { + socket.once("close", () => resolve()); + if (this.connectionClosed) { + // An exited child may leave a TCP peer that never finishes closing. + socket.destroy(); + } else { + timeout = setTimeout( + () => socket.destroy(), + RUNTIME_SHUTDOWN_TIMEOUT_MS + ); + socket.end(); + } + }); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } } } catch (error) { errors.push( @@ -3466,8 +3485,16 @@ export class CopilotClient { const connection = this.connection; const messageWriter = this.messageWriter; + const cliProcess = this.isExternalServer ? null : this.cliProcess; + const connectionEpoch = this.connectionEpoch; + const isCurrentConnection = () => + connectionEpoch === this.connectionEpoch && + this.connection === connection && + (cliProcess === null || this.cliProcess === cliProcess); + let disconnecting = false; + let exitFallback: ReturnType | undefined; const markDisconnected = () => { - if (this.connection !== connection) { + if (!isCurrentConnection()) { connection.dispose(); return; } @@ -3488,18 +3515,47 @@ export class CopilotClient { this._internalRpc = null; this.modelsCache = null; }; - const drainAndDisconnect = () => { + const disconnectAfterDrain = () => { + if (exitFallback !== undefined) { + clearTimeout(exitFallback); + exitFallback = undefined; + } + if (!isCurrentConnection()) { + connection.dispose(); + return; + } + if (disconnecting) return; + disconnecting = true; + this.connectionClosed = true; if (messageWriter) messageWriter.suppressWriteErrors = true; // jsonrpc dispatches parsed messages asynchronously, one per event-loop // turn. Drain them before clearing callbacks and rejecting unanswered RPCs. void connection.drain().then(markDisconnected); }; - this.connection.onClose(drainAndDisconnect); + this.connection.onClose(disconnectAfterDrain); // Descendants can retain inherited output pipes after the runtime exits. // Observe the owned process without waiting for those pipes to reach EOF. - this.cliProcess?.once("exit", drainAndDisconnect); + if (cliProcess) { + cliProcess.once("exit", () => { + if (!isCurrentConnection()) { + connection.dispose(); + return; + } + if (disconnecting) return; + if (this.socket) { + disconnectAfterDrain(); + return; + } + this.connectionClosed = true; + if (messageWriter) messageWriter.suppressWriteErrors = true; + // Reader EOF is authoritative for messages still buffered after process exit. + // A descendant can keep stdout open, so bound the fallback. + exitFallback = setTimeout(disconnectAfterDrain, RUNTIME_SHUTDOWN_TIMEOUT_MS); + exitFallback.unref(); + }); + } this.connection.onError(() => { - if (this.connection === connection) { + if (connectionEpoch === this.connectionEpoch && this.connection === connection) { this.state = "disconnected"; } }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index a37a661cd5..a73a68111c 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -4389,7 +4389,7 @@ export type SandboxConfigSource = /** A repository policy selected the sandbox state. */ | "repository_policy"; /** - * Extensible identifier of a sandbox policy feature whose availability varies between hosts. A plain string, so an older client decodes a name added by a newer runtime; ignore names you do not recognize. Known values: `network` — sandboxed commands can reach the network (`network.allowOutbound`, on by default); on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns. `network_filtering` — host rules and the sandbox proxy (`network.allowedHosts`, `network.blockedHosts`, `network.proxy`); on Linux this needs the same tooling as `network`; on Windows it needs a version with Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`, because Windows reaches the local proxy only together with private-network access. `denied_paths` — native enforcement of `filesystem.deniedPaths`; on Windows this needs a version whose sandbox contract reports denied-path support. `shell` — shell commands inside the sandbox: bash on macOS and Linux, PowerShell on Windows; on Windows this needs a version with Process Security Environment 1.1 filesystem enumeration support. + * Extensible identifier of a sandbox policy feature whose availability varies between hosts. A plain string, so an older client decodes a name added by a newer runtime; ignore names you do not recognize. Known values: `network` — sandboxed commands can reach the network (`network.allowOutbound`, on by default); on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns. `network_filtering` — host rules and the sandbox proxy (`network.allowedHosts`, `network.blockedHosts`, `network.proxy`); on Linux this needs the same tooling as `network`; on Windows it needs a version with Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`, because Windows reaches the local proxy only together with private-network access. `denied_paths` — native enforcement of `filesystem.deniedPaths`; on Windows this needs a version whose sandbox contract reports denied-path support. `shell` — shell commands inside the sandbox: bash on macOS and Linux, PowerShell on Windows. `filesystem_enumeration` — enumerate-only filesystem grants, which PowerShell's drive roots use on Windows; this needs a version with Process Security Environment 1.1 filesystem enumeration support. Without it, sandboxed PowerShell still runs, but `Get-Location` may report the drive root, `Set-Location` may fail, and relative paths may resolve against the drive root; the session also receives a `session.warning` with `warningType` `sandbox`. Other platforms always report it. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SandboxHostCapabilityName". @@ -21217,7 +21217,7 @@ export interface SandboxGrantPathForRequestResult { success: boolean; } /** - * Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`. + * Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`, except `filesystem_enumeration`, whose absence degrades sandboxed PowerShell instead of failing it. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SandboxHostCapability". diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b9360dc60f..dc7b6ba442 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -3,7 +3,7 @@ import { EventEmitter, once } from "node:events"; import { PassThrough } from "stream"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { createServer, type Socket } from "node:net"; +import { createConnection, createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it, onTestFinished, vi } from "vitest"; @@ -4596,6 +4596,321 @@ connection.listen(); } ); + it("disconnects when the owned CLI exits before its connection closes", async () => { + const client = new CopilotClient(); + const oldChild = new EventEmitter(); + const child = new EventEmitter(); + const createConnection = () => ({ + onNotification: vi.fn(), + onRequest: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + drain: vi.fn(async () => {}), + dispose: vi.fn(), + }); + const oldConnection = createConnection(); + const connection = createConnection(); + const finishDrain = Promise.withResolvers(); + connection.drain.mockImplementation(() => finishDrain.promise); + const sessionDisconnected = vi.fn(); + + (client as any).connection = oldConnection; + (client as any).cliProcess = oldChild; + (client as any).attachConnectionHandlers(); + + (client as any).connection = connection; + (client as any).cliProcess = child; + (client as any).connectionClosed = false; + (client as any).state = "connected"; + (client as any).sessions.set("session-1", { + _markDisconnected: sessionDisconnected, + }); + (client as any).attachConnectionHandlers(); + + oldChild.emit("exit", null, "SIGKILL"); + expect((client as any).state).toBe("connected"); + expect(sessionDisconnected).not.toHaveBeenCalled(); + + child.emit("exit", null, "SIGKILL"); + expect((client as any).connectionClosed).toBe(true); + expect(connection.drain).not.toHaveBeenCalled(); + expect(sessionDisconnected).not.toHaveBeenCalled(); + + const onClose = connection.onClose.mock.calls[0]?.[0]; + expect(onClose).toBeDefined(); + onClose(); + expect(connection.drain).toHaveBeenCalledOnce(); + finishDrain.resolve(); + await vi.waitFor(() => { + expect((client as any).state).toBe("disconnected"); + expect(sessionDisconnected).toHaveBeenCalledTimes(1); + }); + expect((client as any).sessions.size).toBe(0); + onClose(); + expect(sessionDisconnected).toHaveBeenCalledTimes(1); + }); + + it("disconnects after a bounded wait if an exited CLI leaves its reader open", async () => { + const client = new CopilotClient(); + const child = new EventEmitter(); + const connection = { + onNotification: vi.fn(), + onRequest: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + drain: vi.fn(async () => {}), + dispose: vi.fn(), + }; + (client as any).connection = connection; + (client as any).cliProcess = child; + (client as any).state = "connected"; + (client as any).attachConnectionHandlers(); + + vi.useFakeTimers(); + try { + child.emit("exit", null, "SIGKILL"); + expect(connection.drain).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10_000); + expect(connection.drain).toHaveBeenCalledOnce(); + expect((client as any).state).toBe("disconnected"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not keep the host alive while waiting for an exited CLI's reader", () => { + const client = new CopilotClient(); + const child = new EventEmitter(); + const connection = { + onNotification: vi.fn(), + onRequest: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + drain: vi.fn(async () => {}), + dispose: vi.fn(), + }; + (client as any).connection = connection; + (client as any).cliProcess = child; + (client as any).attachConnectionHandlers(); + + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + try { + child.emit("exit", null, "SIGKILL"); + const fallback = timeoutSpy.mock.results[0]?.value as NodeJS.Timeout | undefined; + expect(fallback).toBeDefined(); + expect(fallback?.hasRef()).toBe(false); + } finally { + for (const call of timeoutSpy.mock.results) { + clearTimeout(call.value as NodeJS.Timeout); + } + timeoutSpy.mockRestore(); + } + }); + + it("finishes stopping when an exited CLI leaves its TCP peer open", async () => { + const server = createServer({ allowHalfOpen: true }); + let peer: Socket | undefined; + let socket: Socket | undefined; + let timeout: NodeJS.Timeout | undefined; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a TCP server address"); + } + const accepted = new Promise((resolve) => { + server.once("connection", resolve); + }); + const connectedSocket = createConnection(address.port, "127.0.0.1"); + socket = connectedSocket; + await new Promise((resolve, reject) => { + connectedSocket.once("connect", resolve); + connectedSocket.once("error", reject); + }); + peer = await accepted; + expect(peer.destroyed).toBe(false); + expect(socket.destroyed).toBe(false); + const client = new CopilotClient({ + autoStart: false, + connection: RuntimeConnection.forTcp(), + }); + (client as any).socket = socket; + (client as any).connectionClosed = true; + (client as any).cliProcess = { exitCode: null, signalCode: "SIGKILL" }; + + const errors = await Promise.race([ + client.stop(), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("stop() waited for an exited CLI's TCP peer")), + 5_000 + ); + }), + ]); + expect(errors).toEqual([]); + expect(socket.destroyed).toBe(true); + } finally { + if (timeout) clearTimeout(timeout); + socket?.destroy(); + peer?.destroy(); + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + } + }); + + it("tears down an owned CLI when its connected TCP peer holds the socket open", async () => { + const server = createServer({ allowHalfOpen: true }); + let peer: Socket | undefined; + let socket: Socket | undefined; + let stopping: Promise | undefined; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a TCP server address"); + } + const accepted = new Promise((resolve) => + server.once("connection", resolve) + ); + socket = createConnection(address.port, "127.0.0.1"); + await new Promise((resolve, reject) => { + socket?.once("connect", resolve); + socket?.once("error", reject); + }); + peer = await accepted; + + const client = new CopilotClient({ + autoStart: false, + connection: RuntimeConnection.forTcp(), + }); + const child = Object.assign(new EventEmitter(), { + exitCode: null as number | null, + signalCode: null as string | null, + kill: vi.fn(() => { + child.exitCode = 0; + child.emit("exit", 0, null); + return true; + }), + }); + (client as any).socket = socket; + (client as any).connectionClosed = false; + (client as any).cliProcess = child; + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + stopping = client.stop(); + expect(socket.destroyed).toBe(false); + await vi.advanceTimersByTimeAsync(10_000); + expect(socket.destroyed).toBe(true); + expect(await stopping).toEqual([]); + expect(child.kill).toHaveBeenCalledOnce(); + } finally { + socket?.destroy(); + peer?.destroy(); + if (stopping) await stopping; + vi.useRealTimers(); + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + } + }); + + it("does not finish starting after the owned CLI exits before a buffered reply arrives", async () => { + const paths = [resolve("plugins/core")]; + const client = new CopilotClient({ builtinPluginDirectories: paths }); + onTestFinished(() => client.forceStop()); + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }); + const registration = Promise.withResolvers(); + const sendRequest = vi.fn((method: string) => { + if (method !== "plugins.builtin.set") + throw new Error(`Unexpected request: ${method}`); + return registration.promise; + }); + const connection = { + onNotification: vi.fn(), + onRequest: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + sendRequest, + drain: vi.fn(async () => {}), + dispose: vi.fn(), + }; + vi.spyOn(client as any, "startCLIServer").mockImplementation(async () => { + (client as any).cliProcess = child; + }); + vi.spyOn(client as any, "connectToServer").mockImplementation(async () => { + (client as any).connection = connection; + (client as any).attachConnectionHandlers(); + }); + vi.spyOn(client as any, "verifyProtocolVersion").mockResolvedValue(undefined); + + const starting = client.start(); + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith("plugins.builtin.set", { paths }) + ); + child.emit("exit", 1, null); + expect((client as any).connectionClosed).toBe(true); + registration.resolve({}); + + await expect(starting).rejects.toThrow("CLI server connection closed during startup"); + expect((client as any).state).not.toBe("connected"); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + }); + + it("ignores a previous connection's late close while starting a replacement", async () => { + const client = new CopilotClient(); + onTestFinished(() => client.forceStop()); + const oldChild = Object.assign(new EventEmitter(), { kill: vi.fn() }); + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }); + const createConnection = () => ({ + onNotification: vi.fn(), + onRequest: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + drain: vi.fn(async () => {}), + dispose: vi.fn(), + }); + const oldConnection = createConnection(); + const connection = createConnection(); + (client as any).connection = oldConnection; + (client as any).cliProcess = oldChild; + (client as any).attachConnectionHandlers(); + oldChild.emit("exit", 1, null); + await vi.waitFor(() => expect((client as any).state).toBe("disconnected")); + const oldClose = oldConnection.onClose.mock.calls[0]?.[0]; + expect(oldClose).toBeTypeOf("function"); + + vi.spyOn(client as any, "startCLIServer").mockImplementation(async () => { + oldClose(); + (client as any).cliProcess = child; + }); + vi.spyOn(client as any, "connectToServer").mockImplementation(async () => { + (client as any).connection = connection; + (client as any).attachConnectionHandlers(); + }); + vi.spyOn(client as any, "verifyProtocolVersion").mockResolvedValue(undefined); + + await client.start(); + expect((client as any).state).toBe("connected"); + expect((client as any).connectionClosed).toBe(false); + }); + it("requests runtime shutdown when stopping an SDK-owned process", async () => { const client = new CopilotClient(); const calls: string[] = []; diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index a868c076d0..e4e826a922 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -1,4 +1,5 @@ import { ChildProcess } from "child_process"; +import type { Socket } from "node:net"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; @@ -87,25 +88,82 @@ describe("Client", () => { // because the JSON-RPC logic is still trying to write to stdin after // the process has exited. const client = new CopilotClient({ connection: RuntimeConnection.forTcp() }); + let phase = "createSession"; + const startedAt = Date.now(); + let phaseStartedAt = startedAt; + const phaseTimes = ["createSession=0ms"]; + let stopSocket: Socket | null = null; + let stopProcess: ChildProcess | null = null; + const logPendingPhase = (reason: string) => { + const status = client as unknown as { + state: string; + connectionClosed: boolean; + connection: unknown; + socket: Socket | null; + cliProcess: ChildProcess | null; + }; + const socket = stopSocket ?? status.socket; + const child = stopProcess ?? status.cliProcess; + console.warn( + `${reason}: phase=${phase} for ${Date.now() - phaseStartedAt}ms, ` + + `phases=${phaseTimes.join(", ")}, state=${status.state}, ` + + `connectionClosed=${status.connectionClosed}, connectionActive=${status.connection !== null}, ` + + `child exitCode=${child?.exitCode}, signalCode=${child?.signalCode}, ` + + `socket destroyed=${socket?.destroyed}, readyState=${socket?.readyState}` + ); + }; + let diagnosticTimer = setTimeout(() => logPendingPhase("Still waiting"), 20_000); + const startPhase = (nextPhase: string) => { + clearTimeout(diagnosticTimer); + phase = nextPhase; + phaseStartedAt = Date.now(); + phaseTimes.push(`${phase}=${phaseStartedAt - startedAt}ms`); + diagnosticTimer = setTimeout(() => logPendingPhase("Still waiting"), 20_000); + }; + let completed = false; + onTestFinished(async () => { + clearTimeout(diagnosticTimer); + if (!completed) logPendingPhase("Test ended before completion"); + await client.forceStop(); + }); await client.createSession({ onPermissionRequest: approveAll }); - // Kill the server processto force cleanup to fail + startPhase("child exit"); + // Kill the server process to force cleanup to fail // eslint-disable-next-line @typescript-eslint/no-explicit-any const cliProcess = (client as any).cliProcess as ChildProcess; expect(cliProcess).toBeDefined(); - cliProcess.kill("SIGKILL"); + expect(cliProcess.kill("SIGKILL")).toBe(true); await vi.waitFor( () => { - expect((client as unknown as { state: string }).state).toBe("disconnected"); + const socket = ( + client as unknown as { + socket: { destroyed: boolean; readyState: string } | null; + } + ).socket; + expect( + (client as unknown as { state: string }).state, + `After SIGKILL: child exitCode=${cliProcess.exitCode}, signalCode=${cliProcess.signalCode}, ` + + `socket destroyed=${socket?.destroyed}, readyState=${socket?.readyState}` + ).toBe("disconnected"); }, { timeout: 10_000 } ); + stopSocket = (client as unknown as { socket: Socket | null }).socket; + stopProcess = cliProcess; + phaseTimes.push( + `socketAtStop=${stopSocket?.readyState}/${stopSocket?.destroyed}, ` + + `childAtStop=${cliProcess.exitCode}/${cliProcess.signalCode}` + ); + startPhase("client.stop()"); const errors = await client.stop(); + clearTimeout(diagnosticTimer); if (errors.length > 0) { expect(errors[0].message).toContain("Failed to disconnect session"); } + completed = true; }, // Generous timeout: client.stop() must wait for session.detach to time out // when the server process is dead. The default 30s can flake on slow CI under load. diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index f2bb16ca6d..52f81d45b0 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -181,6 +181,52 @@ describe("Pending work resume", async () => { return `localhost:${port}`; } + async function forceStopAfterSessionLockObserved( + suspendedClient: CopilotClient, + sessionId: string + ): Promise { + 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(); + } + } + it( "should continue pending permission request after resume", { timeout: TEST_TIMEOUT_MS }, @@ -223,7 +269,7 @@ describe("Pending work resume", async () => { await permissionRequestedP; expect(initialRequest.kind).toBe("custom-tool"); - await suspendedClient.forceStop(); + await forceStopAfterSessionLockObserved(suspendedClient, sessionId); const resumedTcpClient = createConnectingClient(cliUrl); const session2 = await resumedTcpClient.resumeSession(sessionId, { @@ -301,7 +347,7 @@ describe("Pending work resume", async () => { ) ).toBe("beta"); - await suspendedClient.forceStop(); + await forceStopAfterSessionLockObserved(suspendedClient, sessionId); const resumedClient = createConnectingClient(cliUrl); const session2 = await resumedClient.resumeSession(sessionId, { @@ -380,7 +426,7 @@ describe("Pending work resume", async () => { expect(await originalToolAStarted.promise).toBe("alpha"); expect(await originalToolBStarted.promise).toBe("beta"); - await suspendedClient.forceStop(); + await forceStopAfterSessionLockObserved(suspendedClient, sessionId); const resumedClient = createConnectingClient(cliUrl); const session2 = await resumedClient.resumeSession(sessionId, { @@ -545,46 +591,7 @@ describe("Pending work resume", async () => { } if (scenario.disconnectOriginalClient) { - 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(); - } + await forceStopAfterSessionLockObserved(suspendedClient, sessionId); } const resumedClient = createConnectingClient(cliUrl); diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 90afa68399..d89b2c3e1e 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -278,7 +278,15 @@ describe("Server-scoped RPC", async () => { const host = await client.rpc.sandbox.getHostSupport(); expect(host.reason === undefined).toBe(host.supported); expect(host.capabilities.map((capability) => capability.name).sort()).toEqual( - host.supported ? ["denied_paths", "network", "network_filtering", "shell"] : [] + host.supported + ? [ + "denied_paths", + "filesystem_enumeration", + "network", + "network_filtering", + "shell", + ] + : [] ); for (const capability of host.capabilities) { expect(capability.reason === undefined).toBe(capability.supported); diff --git a/nodejs/test/e2e/workflow.e2e.test.ts b/nodejs/test/e2e/workflow.e2e.test.ts index d5da188691..093654d79a 100644 --- a/nodejs/test/e2e/workflow.e2e.test.ts +++ b/nodejs/test/e2e/workflow.e2e.test.ts @@ -26,7 +26,7 @@ const workflowTestContext = await createSdkTestContext({ copilotClientOptions: { connection: RuntimeConnection.forStdio({ path: cliPath }), env: { - COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS,AGENT_FACTORIES", + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", }, extensionLaunchProvider: { resolve: async (request) => ({ diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index c5aa03d313..637e5cbfad 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -11473,7 +11473,9 @@ def to_dict(self) -> dict: @dataclass class SandboxHostCapability: """Whether this host can run one sandbox policy feature. A session whose effective policy - uses an unsupported feature fails each sandboxed command with `reason`. + uses an unsupported feature fails each sandboxed command with `reason`, except + `filesystem_enumeration`, whose absence degrades sandboxed PowerShell instead of failing + it. """ name: str """The policy feature, as an extensible string: ignore names you do not recognize. Known @@ -11482,9 +11484,11 @@ class SandboxHostCapability: `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`), - `denied_paths` (native enforcement of `filesystem.deniedPaths`), and `shell` (shell - commands inside the sandbox; on Windows this needs Process Security Environment 1.1 - filesystem enumeration support). + `denied_paths` (native enforcement of `filesystem.deniedPaths`), `shell` (shell commands + inside the sandbox), and `filesystem_enumeration` (enumerate-only filesystem grants; on + Windows this needs Process Security Environment 1.1 filesystem enumeration support, and + without it sandboxed PowerShell still runs but cannot resolve its current location; other + platforms always report it). """ supported: bool """Whether this host can run the feature.""" diff --git a/python/e2e/test_session_e2e.py b/python/e2e/test_session_e2e.py index 7934ae52f0..21bb8bd3a4 100644 --- a/python/e2e/test_session_e2e.py +++ b/python/e2e/test_session_e2e.py @@ -1003,15 +1003,23 @@ def on_event(event): events.append(event) unsubscribe = session.on(on_event) - idle_task = get_next_event_of_type(session, "session.idle", timeout=10.0) + idle_task = get_next_event_of_type(session, "session.idle") + started = asyncio.get_running_loop().time() try: # Use a slow command so we can verify send() returns before completion await session.send("Run 'sleep 2 && echo done'") + send_duration = asyncio.get_running_loop().time() - started # send() should return before turn completes (no session.idle yet) assert not any(event.type.value == "session.idle" for event in events) - await idle_task + try: + await idle_task + except TimeoutError as exc: + raise AssertionError( + f"session.idle did not arrive after send() returned in {send_duration:.1f}s; " + f"observed events: {[event.type.value for event in events]}" + ) from exc messages = [event for event in events if event.type.value == "assistant.message"] assert messages assert "done" in messages[-1].data.content diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 22c8d0fe7a..b73e187d2d 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -19089,7 +19089,7 @@ pub struct SandboxGrantPathForRequestResult { pub success: bool, } -/// Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`. +/// Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`, except `filesystem_enumeration`, whose absence degrades sandboxed PowerShell instead of failing it. /// ///
/// @@ -19100,7 +19100,7 @@ pub struct SandboxGrantPathForRequestResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxHostCapability { - /// The policy feature, as an extensible string: ignore names you do not recognize. Known values: `network` (sandboxed commands can reach the network; on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns), `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`), `denied_paths` (native enforcement of `filesystem.deniedPaths`), and `shell` (shell commands inside the sandbox; on Windows this needs Process Security Environment 1.1 filesystem enumeration support). + /// The policy feature, as an extensible string: ignore names you do not recognize. Known values: `network` (sandboxed commands can reach the network; on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns), `network_filtering` (host rules and the sandbox proxy; on Linux this needs the same tooling as `network`; on Windows it needs Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`), `denied_paths` (native enforcement of `filesystem.deniedPaths`), `shell` (shell commands inside the sandbox), and `filesystem_enumeration` (enumerate-only filesystem grants; on Windows this needs Process Security Environment 1.1 filesystem enumeration support, and without it sandboxed PowerShell still runs but cannot resolve its current location; other platforms always report it). pub name: String, /// Human-readable reason and remedy when the feature is unsupported, such as a package to install or an OS update. Present only when `supported` is false. #[serde(skip_serializing_if = "Option::is_none")] @@ -34752,7 +34752,7 @@ pub type McpExecuteSamplingResult = HashMap; ///
pub type McpPlanSecretReference = String; -/// Extensible identifier of a sandbox policy feature whose availability varies between hosts. A plain string, so an older client decodes a name added by a newer runtime; ignore names you do not recognize. Known values: `network` — sandboxed commands can reach the network (`network.allowOutbound`, on by default); on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns. `network_filtering` — host rules and the sandbox proxy (`network.allowedHosts`, `network.blockedHosts`, `network.proxy`); on Linux this needs the same tooling as `network`; on Windows it needs a version with Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`, because Windows reaches the local proxy only together with private-network access. `denied_paths` — native enforcement of `filesystem.deniedPaths`; on Windows this needs a version whose sandbox contract reports denied-path support. `shell` — shell commands inside the sandbox: bash on macOS and Linux, PowerShell on Windows; on Windows this needs a version with Process Security Environment 1.1 filesystem enumeration support. +/// Extensible identifier of a sandbox policy feature whose availability varies between hosts. A plain string, so an older client decodes a name added by a newer runtime; ignore names you do not recognize. Known values: `network` — sandboxed commands can reach the network (`network.allowOutbound`, on by default); on Linux this needs the tooling for Bubblewrap's private network namespace, such as slirp4netns. `network_filtering` — host rules and the sandbox proxy (`network.allowedHosts`, `network.blockedHosts`, `network.proxy`); on Linux this needs the same tooling as `network`; on Windows it needs a version with Process Security Environment 1.1 host-loopback support, and a policy that uses it must also set `network.allowLocalNetwork`, because Windows reaches the local proxy only together with private-network access. `denied_paths` — native enforcement of `filesystem.deniedPaths`; on Windows this needs a version whose sandbox contract reports denied-path support. `shell` — shell commands inside the sandbox: bash on macOS and Linux, PowerShell on Windows. `filesystem_enumeration` — enumerate-only filesystem grants, which PowerShell's drive roots use on Windows; this needs a version with Process Security Environment 1.1 filesystem enumeration support. Without it, sandboxed PowerShell still runs, but `Get-Location` may report the drive root, `Set-Location` may fail, and relative paths may resolve against the drive root; the session also receives a `session.warning` with `warningType` `sandbox`. Other platforms always report it. /// ///
/// diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs index 59b17a4290..a4fab77e5f 100644 --- a/rust/tests/e2e/canvas.rs +++ b/rust/tests/e2e/canvas.rs @@ -545,6 +545,7 @@ async fn resumed_canvas_reattaches_and_routes_all_callbacks() { .into_iter() .next() .expect("declared canvas"); + let mut events = session.subscribe(); session .rpc() .canvas() @@ -556,17 +557,16 @@ async fn resumed_canvas_reattaches_and_routes_all_callbacks() { }) .await .expect("open canvas"); - // RPC completion can precede the event that updates the snapshot cache. - let mut events = session.subscribe(); tokio::time::timeout(Duration::from_secs(10), async { while session.open_canvases().is_empty() { - events.recv().await.expect("opened canvas event"); + events.recv().await.expect("initial canvas opened event"); } }) .await - .expect("opened canvas snapshot"); + .expect("initial canvas snapshot"); let snapshots = session.open_canvases(); assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].instance_id, "counter-resume"); session.rpc().suspend().await.expect("suspend session"); session.stop_event_loop().await; diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 7a461838d9..d352aa3131 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -3,6 +3,8 @@ use std::future::Future; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::ops::Deref; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -1299,14 +1301,20 @@ struct CapiProxy { impl CapiProxy { fn start(repo_root: &Path) -> std::io::Result { - let mut child = Command::new(npx_program()) - .args(["tsx", "server.ts"]) + let mut command = Command::new(node_program()); + command + .args(["--import", "tsx", "server.ts"]) .current_dir(repo_root.join("test").join("harness")) .env("GITHUB_ACTIONS", "true") .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn()?; + .stderr(Stdio::inherit()); + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = command.spawn()?; let stdout = child.stdout.take().expect("proxy stdout"); let (line_tx, line_rx) = std::sync::mpsc::channel(); @@ -1563,10 +1571,6 @@ fn node_program() -> &'static str { if cfg!(windows) { "node.exe" } else { "node" } } -fn npx_program() -> &'static str { - if cfg!(windows) { "npx.cmd" } else { "npx" } -} - #[test] fn e2e_context_isolates_copilot_cache() { let home_dir = tempfile::tempdir().expect("create test home"); From 0584472932d8124dc139f6e4fc35ea76397ef868 Mon Sep 17 00:00:00 2001 From: "copilot-cli-release-app[bot]" Date: Thu, 1 Oct 2026 17:26:08 +0000 Subject: [PATCH 3/5] Update SDK snapshot for Copilot CLI 1.0.91 --- nodejs/package.json | 2 +- nodejs/src/cliVersion.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nodejs/package.json b/nodejs/package.json index 7f6cc0cacb..8db3ef0dd5 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/github/copilot-sdk.git" }, "version": "0.0.0-dev", - "copilotCliVersion": "1.0.91-1", + "copilotCliVersion": "1.0.91", "description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC", "main": "./dist/cjs/index.js", "types": "./dist/index.d.ts", diff --git a/nodejs/src/cliVersion.ts b/nodejs/src/cliVersion.ts index c923a8e653..b938946d9c 100644 --- a/nodejs/src/cliVersion.ts +++ b/nodejs/src/cliVersion.ts @@ -1,3 +1,3 @@ -export const COPILOT_CLI_VERSION = "1.0.91-1"; +export const COPILOT_CLI_VERSION = "1.0.91"; export const COPILOT_CLI_USE_NPM_PACKAGE = false; From 19e9a4b9c620d1032110cb6739961c4c1a651278 Mon Sep 17 00:00:00 2001 From: "copilot-cli-release-app[bot]" Date: Thu, 1 Oct 2026 21:54:42 +0000 Subject: [PATCH 4/5] Update SDK snapshot for Copilot CLI 1.0.92-0 --- .github/workflows/java-publish-maven.yml | 768 ------------ .github/workflows/publish.yml | 1069 ---------------- .github/workflows/release-changelog.lock.yml | 2 +- .github/workflows/release-changelog.md | 3 + .github/workflows/sdk-runtime-artifact.yml | 11 + .github/workflows/sdk-rust.yml | 2 + .github/workflows/sdk.yml | 5 +- BUILD.bazel | 2 + docs/developer-docs/secrets.md | 23 +- docs/features/session-persistence.md | 24 + dotnet/README.md | 10 + dotnet/src/Client.cs | 10 +- dotnet/src/Generated/Rpc.cs | 112 +- dotnet/src/Generated/SessionEvents.cs | 2 +- dotnet/src/Session.cs | 3 + dotnet/src/Types.cs | 20 +- dotnet/src/build/GitHub.Copilot.SDK.targets | 8 +- .../test/Unit/ClientSessionLifetimeTests.cs | 62 +- dotnet/test/Unit/MSBuildTargetsTests.cs | 53 +- dotnet/test/Unit/RpcConnectorsTests.cs | 328 ++++- go/README.md | 11 + go/client.go | 2 + go/client_test.go | 104 ++ go/cmd/bundler/main.go | 22 +- go/cmd/bundler/main_test.go | 112 +- go/connectors_rpc_test.go | 160 +++ go/internal/e2e/event_fidelity_e2e_test.go | 54 +- go/rpc/zrpc.go | 86 +- go/rpc/zsession_events.go | 2 +- go/session.go | 12 + go/types.go | 24 +- java/README.md | 39 +- java/copilot-native/scripts/fetch-native.mjs | 7 +- .../scripts/fetch-native.test.mjs | 36 + java/scripts/codegen/fetch-schemas.mjs | 7 +- java/scripts/codegen/fetch-schemas.test.mjs | 49 + java/scripts/codegen/java.test.ts | 46 + java/scripts/codegen/java.ts | 41 +- .../SessionMcpServerStatusChangedEvent.java | 2 +- .../copilot/generated/rpc/AuthIdentity.java | 37 + .../generated/rpc/AuthIdentityMetadata.java | 35 + .../generated/rpc/ConnectorCatalogEntry.java | 25 + .../rpc/ConnectorSessionAccount.java | 33 + .../generated/rpc/SessionConnectorsApi.java | 31 + .../SessionConnectorsGetAccountParams.java | 30 + ...essionConnectorsGetCapabilitiesResult.java | 27 + .../SessionConnectorsReconcileRequest.java | 98 ++ .../generated/rpc/SessionGitHubAuthApi.java | 16 +- .../com/github/copilot/CopilotClient.java | 1 + .../com/github/copilot/CopilotSession.java | 15 + .../github/copilot/SessionRequestBuilder.java | 1 + .../copilot/rpc/ResumeSessionConfig.java | 36 + .../copilot/rpc/ResumeSessionRequest.java | 16 + .../copilot/rpc/ResumeSessionResponse.java | 21 +- .../copilot/rpc/TranscriptRecoveryReport.java | 24 + .../com/github/copilot/RpcWrappersTest.java | 184 ++- .../copilot/SessionRequestBuilderTest.java | 56 +- .../rpc/ConnectorSerializationTest.java | 93 ++ nodejs/README.md | 14 +- nodejs/package.json | 3 +- nodejs/scripts/package-release.ts | 378 ++++++ nodejs/scripts/publish-release.ts | 374 ++++++ nodejs/scripts/release-manifest.ts | 146 +-- nodejs/scripts/releaseArtifacts.ts | 36 +- nodejs/scripts/runtime-package-acquisition.ts | 251 ---- nodejs/scripts/runtime-release-identity.ts | 213 ---- nodejs/scripts/unified-release.ts | 598 +++++++++ nodejs/src/cliVersion.ts | 2 +- nodejs/src/client.ts | 6 +- nodejs/src/generated/rpc.ts | 75 +- nodejs/src/generated/session-events.ts | 2 +- nodejs/src/index.ts | 5 + nodejs/src/session.ts | 12 + nodejs/src/types.ts | 22 +- nodejs/test/client.test.ts | 42 + nodejs/test/connectors.test.ts | 162 +++ nodejs/test/e2e/connectors.e2e.test.ts | 433 +++++++ .../e2e/mcp_cached_oauth_overlap.e2e.test.ts | 415 +++++++ .../test/e2e/pending_work_resume.e2e.test.ts | 241 +++- .../test/e2e/rpc_surface_coverage.e2e.test.ts | 2 +- .../e2e/rpc_tasks_and_handlers.e2e.test.ts | 94 +- .../test/e2e/transcript_recovery.e2e.test.ts | 88 ++ nodejs/test/npm-release.test.ts | 38 +- nodejs/test/package-sdk.test.ts | 1 + nodejs/test/python-codegen.test.ts | 100 ++ nodejs/test/release-manifest.test.ts | 87 +- .../test/runtime-package-acquisition.test.ts | 237 ---- nodejs/test/runtime-release-identity.test.ts | 226 ---- nodejs/test/runtimeArtifacts.test.ts | 113 +- nodejs/test/rust-codegen.test.ts | 41 + nodejs/test/typescript-codegen.test.ts | 29 + nodejs/test/unified-release.test.ts | 1106 +++++++++++++++++ nodejs/tsconfig.test.json | 1 + python/README.md | 10 + python/copilot/__init__.py | 2 + python/copilot/_cli_version.py | 21 +- python/copilot/client.py | 18 +- python/copilot/generated/rpc.py | 123 +- python/copilot/session.py | 10 + python/test_cli_download.py | 31 +- python/test_client.py | 65 + python/test_connectors.py | 149 +++ rust/Cargo.toml | 16 +- rust/README.md | 87 +- rust/RELEASING.md | 118 +- rust/build/in_process.rs | 328 +++-- rust/build/in_process/tests.rs | 575 +++++++++ rust/scripts/snapshot-bundled-cli-version.sh | 25 +- .../snapshot-bundled-in-process-version.sh | 25 +- rust/scripts/snapshot-release.sh | 86 ++ rust/scripts/snapshot-version.test.mjs | 184 +++ rust/src/ahp_host.rs | 4 + rust/src/ahp_host/factory_tests.rs | 14 +- rust/src/embeddedcli.rs | 19 +- rust/src/generated/api_types.rs | 168 ++- rust/src/generated/rpc.rs | 63 + rust/src/generated/session_events.rs | 2 +- rust/src/lib.rs | 9 +- rust/src/resolve.rs | 8 +- rust/src/session.rs | 13 +- rust/src/types.rs | 63 + rust/src/wire.rs | 2 + rust/tests/api_types_test.rs | 116 +- rust/tests/build_acquisition.rs | 6 + rust/tests/cli_resolution_test.rs | 33 +- rust/tests/e2e/client_options.rs | 58 +- .../fixtures/connector-runtime/budget.ts | 18 + .../connector-runtime/fixture.test.ts | 15 + .../fixtures/connector-runtime/fixture.ts | 248 ++++ .../fixtures/connector-runtime/package.json | 4 + .../fixtures/connector-runtime/src/main.rs | 531 ++++++++ .../connector-runtime/vitest.local.config.ts | 20 + rust/tests/session_test.rs | 55 +- scripts/ci/codegen-entrypoints.test.mjs | 41 + scripts/codegen/csharp.ts | 3 +- scripts/codegen/go.ts | 53 +- scripts/codegen/python.ts | 146 ++- scripts/codegen/rust.ts | 29 +- scripts/codegen/typescript.ts | 13 +- scripts/runtime-release.mjs | 19 + test/harness/certUtils.ts | 52 +- test/harness/replayingCapiProxy.test.ts | 135 ++ test/harness/replayingCapiProxy.ts | 64 + test/harness/test-mcp-oauth-server.mjs | 54 +- ...pleted_siblings_result_on_cold_resume.yaml | 30 + ...l_purpose_agent_on_the_parent_s_model.yaml | 40 + 146 files changed, 10115 insertions(+), 3611 deletions(-) delete mode 100644 .github/workflows/java-publish-maven.yml delete mode 100644 .github/workflows/publish.yml create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/AuthIdentity.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/AuthIdentityMetadata.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ConnectorSessionAccount.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionConnectorsGetAccountParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionConnectorsReconcileRequest.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/TranscriptRecoveryReport.java create mode 100644 nodejs/scripts/package-release.ts create mode 100644 nodejs/scripts/publish-release.ts delete mode 100644 nodejs/scripts/runtime-package-acquisition.ts delete mode 100644 nodejs/scripts/runtime-release-identity.ts create mode 100644 nodejs/scripts/unified-release.ts create mode 100644 nodejs/test/e2e/connectors.e2e.test.ts create mode 100644 nodejs/test/e2e/mcp_cached_oauth_overlap.e2e.test.ts create mode 100644 nodejs/test/e2e/transcript_recovery.e2e.test.ts delete mode 100644 nodejs/test/runtime-package-acquisition.test.ts delete mode 100644 nodejs/test/runtime-release-identity.test.ts create mode 100644 nodejs/test/unified-release.test.ts create mode 100644 rust/build/in_process/tests.rs create mode 100644 rust/scripts/snapshot-release.sh create mode 100644 rust/scripts/snapshot-version.test.mjs create mode 100644 rust/tests/build_acquisition.rs create mode 100644 rust/tests/fixtures/connector-runtime/budget.ts create mode 100644 rust/tests/fixtures/connector-runtime/fixture.test.ts create mode 100644 rust/tests/fixtures/connector-runtime/fixture.ts create mode 100644 rust/tests/fixtures/connector-runtime/package.json create mode 100644 rust/tests/fixtures/connector-runtime/src/main.rs create mode 100644 rust/tests/fixtures/connector-runtime/vitest.local.config.ts create mode 100644 scripts/ci/codegen-entrypoints.test.mjs create mode 100644 scripts/runtime-release.mjs create mode 100644 test/snapshots/pending_work_resume/should_preserve_a_completed_siblings_result_on_cold_resume.yaml create mode 100644 test/snapshots/rpc_tasks_and_handlers/should_start_a_general_purpose_agent_on_the_parent_s_model.yaml diff --git a/.github/workflows/java-publish-maven.yml b/.github/workflows/java-publish-maven.yml deleted file mode 100644 index d8ca560ab7..0000000000 --- a/.github/workflows/java-publish-maven.yml +++ /dev/null @@ -1,768 +0,0 @@ -name: "Java Publish to Maven Central" - -env: - HUSKY: 0 - -# This workflow is a read-only consumer of the commit being released. It builds -# every native classifier and the primary Java SDK artifact from a single -# immutable source SHA, injects the release version with -Drevision=, and -# publishes to Maven Central. It never commits to, tags, or otherwise mutates -# the repository. The cross-language `vX.Y.Z` GitHub Release and the -# `java/vX.Y.Z` traceability tag are created by .github/workflows/publish.yml -# only after publication succeeds. -# Only validated commits from main's history may be published. -# Keep dependency caches isolated from these independently selected sources. - -on: - workflow_dispatch: - inputs: - releaseVersion: - description: "Release version (e.g., 1.0.0). If empty, derives from pom.xml by removing -SNAPSHOT" - required: false - type: string - sourceSha: - description: "Full commit SHA from main's history. Defaults to the triggering commit; dispatch this workflow from main." - required: false - type: string - prerelease: - description: "Is this a prerelease?" - type: boolean - required: false - default: false - workflow_call: - inputs: - releaseVersion: - description: "Release version (e.g., 1.0.0). If empty, derives from pom.xml by removing -SNAPSHOT" - required: false - type: string - sourceSha: - description: "Full commit SHA from main's history. Defaults to the triggering commit; dispatch this workflow from main." - required: false - type: string - prerelease: - description: "Is this a prerelease?" - type: boolean - required: false - default: false - outputs: - mavenPublished: - description: "Whether the Java package was published to Maven Central" - value: ${{ jobs.deploy-maven.outputs.published }} - version: - description: "The published release version" - value: ${{ jobs.resolve-source.outputs.release_version }} - sourceSha: - description: "The immutable source commit that was published" - value: ${{ jobs.resolve-source.outputs.validated_source }} - secrets: - JAVA_MAVEN_CENTRAL_USERNAME: - required: true - JAVA_MAVEN_CENTRAL_PASSWORD: - required: true - JAVA_GPG_SECRET_KEY: - required: true - JAVA_GPG_PASSPHRASE: - required: true - -permissions: - contents: read - -concurrency: - group: publish-maven - cancel-in-progress: false - -jobs: - resolve-source: - name: Resolve immutable release source - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./java - outputs: - validated_source: ${{ steps.source.outputs.validated_source }} - release_version: ${{ steps.versions.outputs.release_version }} - steps: - - name: Require a main-branch publication - working-directory: . - env: - WORKFLOW_REF: ${{ github.ref }} - run: | - if [ "$WORKFLOW_REF" != "refs/heads/main" ]; then - echo "::error::Java publication must be dispatched from main." - exit 1 - fi - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - name: Validate and check out the release source - id: source - working-directory: . - env: - REQUESTED_SOURCE: ${{ inputs.sourceSha || github.sha }} - WORKFLOW_REF: ${{ github.ref }} - run: | - set -euo pipefail - VALIDATED_SOURCE=$(node java/scripts/resolve-release-source.mjs) - git checkout --detach "$VALIDATED_SOURCE" - echo "validated_source=$VALIDATED_SOURCE" >> "$GITHUB_OUTPUT" - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - - - name: Determine release version - id: versions - env: - REQUESTED_VERSION: ${{ inputs.releaseVersion }} - run: | - CURRENT_VERSION=$(mvn -N help:evaluate -Dexpression=project.version -q -DforceStdout) - echo "Current pom.xml version: $CURRENT_VERSION" - - if [ -n "$REQUESTED_VERSION" ]; then - RELEASE_VERSION="$REQUESTED_VERSION" - else - RELEASE_VERSION="${CURRENT_VERSION%-SNAPSHOT}" - fi - echo "Release version: $RELEASE_VERSION" - - if ! echo "$RELEASE_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-(preview|(beta-)?java(-preview)?)\.[0-9]+)?$'; then - echo "::error::RELEASE_VERSION '$RELEASE_VERSION' is invalid." - exit 1 - fi - if [[ "$RELEASE_VERSION" == *-SNAPSHOT ]]; then - echo "::error::RELEASE_VERSION '$RELEASE_VERSION' must not be a SNAPSHOT." - exit 1 - fi - - echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" - - build-linux-arm64-classifier: - name: Build Linux ARM64 native classifier - needs: resolve-source - runs-on: ubuntu-24.04-arm - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./java - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-source.outputs.validated_source }} - fetch-depth: 1 - persist-credentials: false - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - name: Build and validate linux-arm64 classifier - run: | - set -euo pipefail - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - node copilot-native/scripts/validate-native-host.mjs linux-arm64 - mvn -B -pl copilot-native package -DskipTests -Drevision="$VERSION" -Dcopilot.native.libc=glibc - JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION-linux-arm64.jar" - PRIMARY_JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION.jar" - test -f "$JAR" - node copilot-native/scripts/validate-native-artifact.mjs \ - classifier linux-arm64 "$JAR" "$(basename "$JAR")" .. - node copilot-native/scripts/validate-native-artifact.mjs placeholder "$PRIMARY_JAR" - MANIFEST="copilot-native/target/linux-arm64-$VERSION.sha256" - HASH=$(sha256sum "$JAR" | cut -d ' ' -f 1) - printf '%s %s' "$HASH" "$(basename "$JAR")" > "$MANIFEST" - node copilot-native/scripts/validate-native-artifact.mjs \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: java-native-linux-arm64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: | - java/copilot-native/target/copilot-sdk-java-runtime-${{ needs.resolve-source.outputs.release_version }}-linux-arm64.jar - java/copilot-native/target/linux-arm64-${{ needs.resolve-source.outputs.release_version }}.sha256 - if-no-files-found: error - retention-days: 1 - - build-linuxmusl-x64-classifier: - name: Build Linux musl x64 native classifier - needs: resolve-source - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./java - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-source.outputs.validated_source }} - fetch-depth: 1 - persist-credentials: false - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - cache: "maven" - - - name: Build and validate linuxmusl-x64 classifier - run: | - set -euo pipefail - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - docker run --rm \ - --volume "$GITHUB_WORKSPACE:/workspace" \ - --volume "$HOME/.m2:/root/.m2" \ - --workdir /workspace/java \ - --env HOST_GID="$(id -g)" \ - --env HOST_UID="$(id -u)" \ - "eclipse-temurin:25-jdk-alpine" \ - sh -c "apk add --no-cache git java-cacerts maven nodejs npm && - export JAVA_TOOL_OPTIONS=-Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts && - git config --global --add safe.directory /workspace && - node copilot-native/scripts/validate-native-host.mjs linuxmusl-x64 && - mvn -B -pl copilot-native package -DskipTests -Dcopilot.native.libc=musl -Drevision=$VERSION && - chown -R \$HOST_UID:\$HOST_GID copilot-native/target" - JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION-linuxmusl-x64.jar" - PRIMARY_JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION.jar" - test -f "$JAR" - node copilot-native/scripts/validate-native-artifact.mjs \ - classifier linuxmusl-x64 "$JAR" "$(basename "$JAR")" .. - node copilot-native/scripts/validate-native-artifact.mjs placeholder "$PRIMARY_JAR" - MANIFEST="copilot-native/target/linuxmusl-x64-$VERSION.sha256" - HASH=$(sha256sum "$JAR" | cut -d ' ' -f 1) - printf '%s %s' "$HASH" "$(basename "$JAR")" > "$MANIFEST" - node copilot-native/scripts/validate-native-artifact.mjs \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: java-native-linuxmusl-x64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: | - java/copilot-native/target/copilot-sdk-java-runtime-${{ needs.resolve-source.outputs.release_version }}-linuxmusl-x64.jar - java/copilot-native/target/linuxmusl-x64-${{ needs.resolve-source.outputs.release_version }}.sha256 - if-no-files-found: error - retention-days: 1 - - build-windows-x64-classifier: - name: Build Windows x64 native classifier - needs: resolve-source - runs-on: windows-latest - permissions: - contents: read - defaults: - run: - shell: pwsh - working-directory: ./java - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-source.outputs.validated_source }} - fetch-depth: 1 - persist-credentials: false - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - name: Build and validate win32-x64 classifier - run: | - $sourceCommit = git rev-parse HEAD - if ($sourceCommit -ne '${{ needs.resolve-source.outputs.validated_source }}') { - throw "Checked out $sourceCommit instead of the resolved release source." - } - $version = '${{ needs.resolve-source.outputs.release_version }}' - node copilot-native/scripts/validate-native-host.mjs win32-x64 - mvn -B -pl copilot-native package -DskipTests "-Drevision=$version" - $jar = "copilot-native/target/copilot-sdk-java-runtime-$version-win32-x64.jar" - $primaryJar = "copilot-native/target/copilot-sdk-java-runtime-$version.jar" - if (-not (Test-Path -LiteralPath $jar -PathType Leaf)) { - throw "Expected Windows classifier was not produced: $jar" - } - node copilot-native/scripts/validate-native-artifact.mjs classifier win32-x64 $jar ([IO.Path]::GetFileName($jar)) .. - node copilot-native/scripts/validate-native-artifact.mjs placeholder $primaryJar - $manifest = "copilot-native/target/win32-x64-$version.sha256" - $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $jar).Hash.ToLowerInvariant() - "$hash $([IO.Path]::GetFileName($jar))" | Set-Content -NoNewline -Encoding ascii $manifest - node copilot-native/scripts/validate-native-artifact.mjs checksum $jar $manifest ([IO.Path]::GetFileName($jar)) - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: java-native-win32-x64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: | - java/copilot-native/target/copilot-sdk-java-runtime-${{ needs.resolve-source.outputs.release_version }}-win32-x64.jar - java/copilot-native/target/win32-x64-${{ needs.resolve-source.outputs.release_version }}.sha256 - if-no-files-found: error - retention-days: 1 - - build-windows-arm64-classifier: - name: Build Windows ARM64 native classifier - needs: resolve-source - runs-on: windows-11-arm - permissions: - contents: read - defaults: - run: - shell: pwsh - working-directory: ./java - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-source.outputs.validated_source }} - fetch-depth: 1 - persist-credentials: false - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - name: Build and validate win32-arm64 classifier - run: | - $sourceCommit = git rev-parse HEAD - if ($sourceCommit -ne '${{ needs.resolve-source.outputs.validated_source }}') { - throw "Checked out $sourceCommit instead of the resolved release source." - } - $version = '${{ needs.resolve-source.outputs.release_version }}' - node copilot-native/scripts/validate-native-host.mjs win32-arm64 - mvn -B -pl copilot-native package -DskipTests "-Drevision=$version" - $jar = "copilot-native/target/copilot-sdk-java-runtime-$version-win32-arm64.jar" - $primaryJar = "copilot-native/target/copilot-sdk-java-runtime-$version.jar" - if (-not (Test-Path -LiteralPath $jar -PathType Leaf)) { - throw "Expected Windows ARM64 classifier was not produced: $jar" - } - node copilot-native/scripts/validate-native-artifact.mjs classifier win32-arm64 $jar ([IO.Path]::GetFileName($jar)) .. - node copilot-native/scripts/validate-native-artifact.mjs placeholder $primaryJar - $manifest = "copilot-native/target/win32-arm64-$version.sha256" - $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $jar).Hash.ToLowerInvariant() - "$hash $([IO.Path]::GetFileName($jar))" | Set-Content -NoNewline -Encoding ascii $manifest - node copilot-native/scripts/validate-native-artifact.mjs checksum $jar $manifest ([IO.Path]::GetFileName($jar)) - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: java-native-win32-arm64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: | - java/copilot-native/target/copilot-sdk-java-runtime-${{ needs.resolve-source.outputs.release_version }}-win32-arm64.jar - java/copilot-native/target/win32-arm64-${{ needs.resolve-source.outputs.release_version }}.sha256 - if-no-files-found: error - retention-days: 1 - - build-darwin-classifier: - name: Build Darwin native classifier - needs: resolve-source - runs-on: macos-26 - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./java - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-source.outputs.validated_source }} - fetch-depth: 1 - persist-credentials: false - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - name: Build and validate darwin-arm64 classifier - run: | - set -euo pipefail - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - node copilot-native/scripts/validate-native-host.mjs darwin-arm64 - mvn -B -pl copilot-native package -DskipTests -Drevision="$VERSION" - JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION-darwin-arm64.jar" - PRIMARY_JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION.jar" - test -f "$JAR" - node copilot-native/scripts/validate-native-artifact.mjs \ - classifier darwin-arm64 "$JAR" "$(basename "$JAR")" .. - node copilot-native/scripts/validate-native-artifact.mjs placeholder "$PRIMARY_JAR" - MANIFEST="copilot-native/target/darwin-arm64-$VERSION.sha256" - HASH=$(shasum -a 256 "$JAR" | cut -d ' ' -f 1) - printf '%s %s' "$HASH" "$(basename "$JAR")" > "$MANIFEST" - node copilot-native/scripts/validate-native-artifact.mjs \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: java-native-darwin-arm64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: | - java/copilot-native/target/copilot-sdk-java-runtime-${{ needs.resolve-source.outputs.release_version }}-darwin-arm64.jar - java/copilot-native/target/darwin-arm64-${{ needs.resolve-source.outputs.release_version }}.sha256 - if-no-files-found: error - retention-days: 1 - - build-darwin-x64-classifier: - name: Build Darwin x64 native classifier - needs: resolve-source - runs-on: macos-15-intel - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./java - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-source.outputs.validated_source }} - fetch-depth: 1 - persist-credentials: false - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - name: Build and validate darwin-x64 classifier - run: | - set -euo pipefail - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - node copilot-native/scripts/validate-native-host.mjs darwin-x64 - mvn -B -pl copilot-native package -DskipTests -Drevision="$VERSION" - JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION-darwin-x64.jar" - PRIMARY_JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION.jar" - test -f "$JAR" - node copilot-native/scripts/validate-native-artifact.mjs \ - classifier darwin-x64 "$JAR" "$(basename "$JAR")" .. - node copilot-native/scripts/validate-native-artifact.mjs placeholder "$PRIMARY_JAR" - MANIFEST="copilot-native/target/darwin-x64-$VERSION.sha256" - HASH=$(shasum -a 256 "$JAR" | cut -d ' ' -f 1) - printf '%s %s' "$HASH" "$(basename "$JAR")" > "$MANIFEST" - node copilot-native/scripts/validate-native-artifact.mjs \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: java-native-darwin-x64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: | - java/copilot-native/target/copilot-sdk-java-runtime-${{ needs.resolve-source.outputs.release_version }}-darwin-x64.jar - java/copilot-native/target/darwin-x64-${{ needs.resolve-source.outputs.release_version }}.sha256 - if-no-files-found: error - retention-days: 1 - - deploy-maven: - name: Deploy Java release to Maven Central - needs: - [ - resolve-source, - build-linux-arm64-classifier, - build-linuxmusl-x64-classifier, - build-windows-x64-classifier, - build-windows-arm64-classifier, - build-darwin-classifier, - build-darwin-x64-classifier, - ] - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./java - outputs: - version: ${{ needs.resolve-source.outputs.release_version }} - published: ${{ steps.publish-maven.outcome == 'success' }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.resolve-source.outputs.validated_source }} - fetch-depth: 1 - persist-credentials: false - - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "25" - distribution: "microsoft" - server-id: central - server-username: MAVEN_USERNAME - server-password: MAVEN_PASSWORD - gpg-private-key: ${{ secrets.JAVA_GPG_SECRET_KEY }} - gpg-passphrase: JAVA_GPG_PASSPHRASE - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 - package-manager-cache: false - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: java-native-linux-arm64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/java-native-linux-arm64 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: java-native-linuxmusl-x64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/java-native-linuxmusl-x64 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: java-native-win32-x64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/java-native-win32-x64 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: java-native-win32-arm64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/java-native-win32-arm64 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: java-native-darwin-arm64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/java-native-darwin-arm64 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: java-native-darwin-x64-release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/java-native-darwin-x64 - - - name: Verify immutable source and Linux ARM64 classifier - id: linux-arm64-artifact - run: | - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - ARTIFACT_DIRECTORY="${{ runner.temp }}/java-native-linux-arm64" - JAR="$ARTIFACT_DIRECTORY/copilot-sdk-java-runtime-$VERSION-linux-arm64.jar" - MANIFEST="$ARTIFACT_DIRECTORY/linux-arm64-$VERSION.sha256" - test -f "$JAR" - test -f "$MANIFEST" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - classifier linux-arm64 "$JAR" "$(basename "$JAR")" "$GITHUB_WORKSPACE" - echo "linux_arm64_jar=$JAR" >> "$GITHUB_OUTPUT" - echo "linux_arm64_sha=$(cut -d ' ' -f 1 "$MANIFEST")" >> "$GITHUB_OUTPUT" - - - name: Verify immutable source and Windows classifier - id: windows-artifact - run: | - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - ARTIFACT_DIRECTORY="${{ runner.temp }}/java-native-win32-x64" - JAR="$ARTIFACT_DIRECTORY/copilot-sdk-java-runtime-$VERSION-win32-x64.jar" - MANIFEST="$ARTIFACT_DIRECTORY/win32-x64-$VERSION.sha256" - test -f "$JAR" - test -f "$MANIFEST" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - classifier win32-x64 "$JAR" "$(basename "$JAR")" "$GITHUB_WORKSPACE" - echo "windows_jar=$JAR" >> "$GITHUB_OUTPUT" - echo "windows_sha=$(cut -d ' ' -f 1 "$MANIFEST")" >> "$GITHUB_OUTPUT" - - - name: Verify immutable source and Linux musl x64 classifier - id: linuxmusl-x64-artifact - run: | - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - ARTIFACT_DIRECTORY="${{ runner.temp }}/java-native-linuxmusl-x64" - JAR="$ARTIFACT_DIRECTORY/copilot-sdk-java-runtime-$VERSION-linuxmusl-x64.jar" - MANIFEST="$ARTIFACT_DIRECTORY/linuxmusl-x64-$VERSION.sha256" - test -f "$JAR" - test -f "$MANIFEST" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - classifier linuxmusl-x64 "$JAR" "$(basename "$JAR")" "$GITHUB_WORKSPACE" - echo "linuxmusl_x64_jar=$JAR" >> "$GITHUB_OUTPUT" - echo "linuxmusl_x64_sha=$(cut -d ' ' -f 1 "$MANIFEST")" >> "$GITHUB_OUTPUT" - - - name: Verify immutable source and Darwin classifier - id: darwin-artifact - run: | - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - ARTIFACT_DIRECTORY="${{ runner.temp }}/java-native-darwin-arm64" - JAR="$ARTIFACT_DIRECTORY/copilot-sdk-java-runtime-$VERSION-darwin-arm64.jar" - MANIFEST="$ARTIFACT_DIRECTORY/darwin-arm64-$VERSION.sha256" - test -f "$JAR" - test -f "$MANIFEST" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - classifier darwin-arm64 "$JAR" "$(basename "$JAR")" "$GITHUB_WORKSPACE" - echo "darwin_jar=$JAR" >> "$GITHUB_OUTPUT" - echo "darwin_sha=$(cut -d ' ' -f 1 "$MANIFEST")" >> "$GITHUB_OUTPUT" - - - name: Verify immutable source and Windows ARM64 classifier - id: windows-arm64-artifact - run: | - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - ARTIFACT_DIRECTORY="${{ runner.temp }}/java-native-win32-arm64" - JAR="$ARTIFACT_DIRECTORY/copilot-sdk-java-runtime-$VERSION-win32-arm64.jar" - MANIFEST="$ARTIFACT_DIRECTORY/win32-arm64-$VERSION.sha256" - test -f "$JAR" - test -f "$MANIFEST" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - classifier win32-arm64 "$JAR" "$(basename "$JAR")" "$GITHUB_WORKSPACE" - echo "windows_arm64_jar=$JAR" >> "$GITHUB_OUTPUT" - echo "windows_arm64_sha=$(cut -d ' ' -f 1 "$MANIFEST")" >> "$GITHUB_OUTPUT" - - - name: Verify immutable source and Darwin x64 classifier - id: darwin-x64-artifact - run: | - SOURCE_COMMIT=$(git rev-parse HEAD) - if [ "$SOURCE_COMMIT" != "${{ needs.resolve-source.outputs.validated_source }}" ]; then - echo "::error::Checked out $SOURCE_COMMIT instead of the resolved release source." - exit 1 - fi - VERSION="${{ needs.resolve-source.outputs.release_version }}" - ARTIFACT_DIRECTORY="${{ runner.temp }}/java-native-darwin-x64" - JAR="$ARTIFACT_DIRECTORY/copilot-sdk-java-runtime-$VERSION-darwin-x64.jar" - MANIFEST="$ARTIFACT_DIRECTORY/darwin-x64-$VERSION.sha256" - test -f "$JAR" - test -f "$MANIFEST" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - checksum "$JAR" "$MANIFEST" "$(basename "$JAR")" - node "$GITHUB_WORKSPACE/java/copilot-native/scripts/validate-native-artifact.mjs" \ - classifier darwin-x64 "$JAR" "$(basename "$JAR")" "$GITHUB_WORKSPACE" - echo "darwin_x64_jar=$JAR" >> "$GITHUB_OUTPUT" - echo "darwin_x64_sha=$(cut -d ' ' -f 1 "$MANIFEST")" >> "$GITHUB_OUTPUT" - - - name: Build Linux classifier and deploy complete release - id: publish-maven - run: | - VERSION="${{ needs.resolve-source.outputs.release_version }}" - mvn -B deploy -DskipTests -DskipITs -Prelease -Drevision="$VERSION" -Dcopilot.native.libc=glibc \ - "-Dcopilot.native.external.linux.arm64.classifier.path=${{ steps.linux-arm64-artifact.outputs.linux_arm64_jar }}" \ - "-Dcopilot.native.external.linuxmusl.x64.classifier.path=${{ steps.linuxmusl-x64-artifact.outputs.linuxmusl_x64_jar }}" \ - "-Dcopilot.native.external.win32.classifier.path=${{ steps.windows-artifact.outputs.windows_jar }}" \ - "-Dcopilot.native.external.win32.arm64.classifier.path=${{ steps.windows-arm64-artifact.outputs.windows_arm64_jar }}" \ - "-Dcopilot.native.external.darwin.classifier.path=${{ steps.darwin-artifact.outputs.darwin_jar }}" \ - "-Dcopilot.native.external.darwin.x64.classifier.path=${{ steps.darwin-x64-artifact.outputs.darwin_x64_jar }}" - LINUX_JAR="copilot-native/target/copilot-sdk-java-runtime-$VERSION-linux-x64.jar" - test -f "$LINUX_JAR" - node copilot-native/scripts/validate-native-artifact.mjs \ - classifier linux-x64 "$LINUX_JAR" "$(basename "$LINUX_JAR")" .. - LINUX_SHA=$(sha256sum "$LINUX_JAR" | cut -d ' ' -f 1) - GROUP_ID=$(mvn -q -pl copilot-native help:evaluate -Dexpression=project.groupId -Drevision="$VERSION" -DforceStdout) - ARTIFACT_ID=$(mvn -q -pl copilot-native help:evaluate -Dexpression=project.artifactId -Drevision="$VERSION" -DforceStdout) - POM_VERSION=$(mvn -q -pl copilot-native help:evaluate -Dexpression=project.version -Drevision="$VERSION" -DforceStdout) - if [ -z "$GROUP_ID" ] || [ -z "$ARTIFACT_ID" ] || [ "$POM_VERSION" != "$VERSION" ]; then - echo "::error::Unexpected copilot-native Maven coordinates: $GROUP_ID:$ARTIFACT_ID:$POM_VERSION (expected version $VERSION)" - exit 1 - fi - { - echo "### Maven Central Release" - echo "- **Version:** $VERSION" - echo "- **Source commit:** \`${{ needs.resolve-source.outputs.validated_source }}\`" - echo "- **Repository:** Maven Central" - echo "" - echo "#### Maven Coordinates" - echo "" - echo '```xml' - echo "" - echo " $GROUP_ID" - echo " $ARTIFACT_ID" - echo " $POM_VERSION" - echo "" - echo '```' - echo "" - echo "#### Published Native Classifiers" - echo "" - echo "| Classifier | Build runner | Artifact | SHA-256 | Status |" - echo "| --- | --- | --- | --- | --- |" - echo "| \`linux-x64\` | \`ubuntu-latest\` | \`$(basename "$LINUX_JAR")\` | \`$LINUX_SHA\` | Published |" - echo "| \`linux-arm64\` | \`ubuntu-24.04-arm\` | \`$(basename "${{ steps.linux-arm64-artifact.outputs.linux_arm64_jar }}")\` | \`${{ steps.linux-arm64-artifact.outputs.linux_arm64_sha }}\` | Published |" - echo "| \`linuxmusl-x64\` | \`Alpine x64\` | \`$(basename "${{ steps.linuxmusl-x64-artifact.outputs.linuxmusl_x64_jar }}")\` | \`${{ steps.linuxmusl-x64-artifact.outputs.linuxmusl_x64_sha }}\` | Published |" - echo "| \`win32-x64\` | \`windows-latest\` | \`$(basename "${{ steps.windows-artifact.outputs.windows_jar }}")\` | \`${{ steps.windows-artifact.outputs.windows_sha }}\` | Published |" - echo "| \`win32-arm64\` | \`windows-11-arm\` | \`$(basename "${{ steps.windows-arm64-artifact.outputs.windows_arm64_jar }}")\` | \`${{ steps.windows-arm64-artifact.outputs.windows_arm64_sha }}\` | Published |" - echo "| \`darwin-x64\` | \`macos-15-intel\` | \`$(basename "${{ steps.darwin-x64-artifact.outputs.darwin_x64_jar }}")\` | \`${{ steps.darwin-x64-artifact.outputs.darwin_x64_sha }}\` | Published |" - echo "| \`darwin-arm64\` | \`macos-26\` | \`$(basename "${{ steps.darwin-artifact.outputs.darwin_jar }}")\` | \`${{ steps.darwin-artifact.outputs.darwin_sha }}\` | Published |" - } >> "$GITHUB_STEP_SUMMARY" - env: - MAVEN_USERNAME: ${{ secrets.JAVA_MAVEN_CENTRAL_USERNAME }} - MAVEN_PASSWORD: ${{ secrets.JAVA_MAVEN_CENTRAL_PASSWORD }} - JAVA_GPG_PASSPHRASE: ${{ secrets.JAVA_GPG_PASSPHRASE }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 6a13c57ee2..0000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,1069 +0,0 @@ -name: Publish SDK packages - -env: - HUSKY: 0 - -on: - workflow_dispatch: - inputs: - dist-tag: - description: "Tag to publish under" - type: choice - required: true - default: "prerelease" - options: - - latest - - prerelease - - unstable - - canary - version: - description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments. Unstable overrides must use -unstable." - type: string - required: false - mode: - description: "Publish or validate without registry and release mutations" - type: choice - required: true - default: publish - options: - - publish - - dry-run - runtime: - description: "Runtime metadata (automation only)" - type: string - required: false - test-policy: - description: "Runtime E2E test policy" - type: choice - required: true - default: required - options: - - required - - advisory - - skipped - -permissions: - contents: read - -concurrency: - group: ${{ inputs.mode == 'dry-run' && format('publish-dry-run-{0}', github.run_id) || inputs.runtime != '' && format('publish-runtime-{0}', github.run_id) || inputs.dist-tag == 'unstable' && 'sdk-runtime-public-unstable' || 'publish' }} - cancel-in-progress: false - -jobs: - validate-dispatch: - name: Validate dispatch - runs-on: ubuntu-latest - outputs: - kind: ${{ steps.validate.outputs.kind }} - runtime_run_id: ${{ steps.validate.outputs.runtime_run_id }} - runtime_sha: ${{ steps.validate.outputs.runtime_sha }} - runtime_version: ${{ steps.validate.outputs.runtime_version }} - test_policy: ${{ steps.validate.outputs.test_policy }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: "22.x" - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Validate release dispatch - id: validate - working-directory: ./nodejs - env: - DIST_TAG: ${{ inputs.dist-tag }} - MODE: ${{ inputs.mode }} - RUNTIME_JSON: ${{ inputs.runtime }} - TEST_POLICY: ${{ inputs.test-policy }} - VERSION_OVERRIDE: ${{ inputs.version }} - run: npx tsx scripts/runtime-release-identity.ts - - # Shared job to calculate version once for all publish jobs - version: - name: Calculate Version - needs: validate-dispatch - if: needs.validate-dispatch.outputs.kind == 'direct' - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - outputs: - version: ${{ steps.unstable_version.outputs.VERSION || steps.version.outputs.VERSION }} - current: ${{ steps.version.outputs.CURRENT }} - current-prerelease: ${{ steps.version.outputs.CURRENT_PRERELEASE }} - defaults: - run: - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }} - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: "22.x" - - run: npm ci --ignore-scripts - - name: Plan unstable version - if: inputs.dist-tag == 'unstable' - id: unstable_version - env: - GH_TOKEN: ${{ github.token }} - SDK_CHANNEL: unstable - SDK_SHA: ${{ github.sha }} - SDK_VERSION_OVERRIDE: ${{ inputs.version }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - run: | - set -euo pipefail - WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | - jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" - export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" - export WORKFLOW_CREATED_AT - VERSION="$(npx tsx scripts/unstable-version.ts)" - npm exec -- semver "$VERSION" >/dev/null - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "Planned unstable version: $VERSION" >> "$GITHUB_STEP_SUMMARY" - - name: Get version - if: inputs.dist-tag != 'unstable' - id: version - run: | - CURRENT="$(node scripts/get-version.js current)" - echo "CURRENT=$CURRENT" >> $GITHUB_OUTPUT - echo "Current latest version: $CURRENT" >> $GITHUB_STEP_SUMMARY - CURRENT_PRERELEASE="$(node scripts/get-version.js current-prerelease)" - echo "CURRENT_PRERELEASE=$CURRENT_PRERELEASE" >> $GITHUB_OUTPUT - echo "Current prerelease version: $CURRENT_PRERELEASE" >> $GITHUB_STEP_SUMMARY - if [ -n "${{ github.event.inputs.version }}" ]; then - VERSION="${{ github.event.inputs.version }}" - # Validate version format matches dist-tag - if [ "${{ github.event.inputs.dist-tag }}" = "latest" ]; then - if [[ "$VERSION" == *-* ]]; then - echo "❌ Error: Version '$VERSION' has a prerelease suffix but dist-tag is 'latest'" >> $GITHUB_STEP_SUMMARY - echo "Use a version without suffix (e.g., '1.0.0') for latest releases" - exit 1 - fi - else - if [[ "$VERSION" != *-* ]]; then - echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY - echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease" - exit 1 - fi - fi - echo "Using manual version override: $VERSION" >> $GITHUB_STEP_SUMMARY - else - VERSION="$(node scripts/get-version.js ${{ github.event.inputs.dist-tag }})" - echo "Auto-incremented version: $VERSION" >> $GITHUB_STEP_SUMMARY - fi - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - name: Verify version is available on public npm - if: inputs.dist-tag != 'unstable' - env: - VERSION: ${{ steps.version.outputs.VERSION }} - run: | - node scripts/npm-release.js preflight \ - @github/copilot-sdk \ - "$VERSION" \ - https://registry.npmjs.org - - package-nodejs: - name: Package Node.js SDK - needs: version - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: "22.x" - - run: npm ci --ignore-scripts - - name: Set version - run: node scripts/set-version.js - env: - VERSION: ${{ needs.version.outputs.version }} - - name: Build - run: npm run build - - name: Pack - run: | - npm run pack:release - TARBALL_COUNT="$(find . -maxdepth 1 -name 'github-copilot-sdk-*.tgz' | wc -l | tr -d ' ')" - if [ "$TARBALL_COUNT" -ne 9 ]; then - echo "::error::Expected nine Node.js package tarballs, found $TARBALL_COUNT." - exit 1 - fi - npm run verify:release-packages - - name: Create unstable package manifest - if: inputs.dist-tag == 'unstable' - env: - SDK_VERSION: ${{ needs.version.outputs.version }} - run: npm run release:manifest -- create-package-set package-set-manifest.json . - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: nodejs-package - path: | - nodejs/github-copilot-sdk-*.tgz - nodejs/package-set-manifest.json - if-no-files-found: error - - publish-nodejs: - name: Publish Node.js SDK - needs: package-nodejs - if: inputs.mode == 'publish' && (github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable') - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - id-token: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: "22.x" - - name: Install release dependencies - if: inputs.dist-tag == 'unstable' - working-directory: ./nodejs - run: npm ci --ignore-scripts - - name: Update npm for OIDC support - run: npm i -g "npm@11.6.3" - - name: Download Node.js package - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: nodejs-package - path: ./dist - - name: Publish tarball to public npm - env: - DIST_TAG: ${{ github.event.inputs.dist-tag }} - run: | - set -euo pipefail - if [ "$DIST_TAG" = "unstable" ]; then - node nodejs/scripts/npm-release.js publish-manifest \ - dist/package-set-manifest.json dist unstable https://registry.npmjs.org public - exit 0 - fi - shopt -s nullglob - TARBALLS=(./dist/*.tgz) - if [ "${#TARBALLS[@]}" -ne 9 ]; then - echo "::error::Expected nine Node.js package tarballs, found ${#TARBALLS[@]}." - exit 1 - fi - MAIN_TARBALL="" - for TARBALL in "${TARBALLS[@]}"; do - PACKAGE_NAME="$(tar -xOf "$TARBALL" package/package.json | jq -r .name)" - if [ "$PACKAGE_NAME" = "@github/copilot-sdk" ]; then - MAIN_TARBALL="$TARBALL" - continue - fi - node nodejs/scripts/npm-release.js publish \ - "$TARBALL" \ - "$DIST_TAG" \ - https://registry.npmjs.org \ - public - done - if [ -z "$MAIN_TARBALL" ]; then - echo "::error::Main @github/copilot-sdk tarball not found." - exit 1 - fi - node nodejs/scripts/npm-release.js publish \ - "$MAIN_TARBALL" \ - "$DIST_TAG" \ - https://registry.npmjs.org \ - public - - publish-nodejs-internal: - name: Publish Node.js SDK to internal feed - needs: publish-nodejs - environment: cicd - runs-on: ubuntu-latest - concurrency: - group: sdk-runtime-internal-${{ inputs.dist-tag }} - cancel-in-progress: false - queue: max - permissions: - actions: read - contents: read - id-token: write - env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: "22.x" - - name: Install release dependencies - if: inputs.dist-tag == 'unstable' - working-directory: ./nodejs - run: npm ci --ignore-scripts - - name: Download Node.js package - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: nodejs-package - path: ./dist - - name: Azure Login (OIDC -> id-cpd-ci) - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - client-id: "${{ vars.CPD_ID_CLIENT_ID }}" # id-cpd-ci - tenant-id: "${{ vars.CPD_ID_TENANT_ID }}" - allow-no-subscriptions: true - - name: Configure feed auth - run: | - set -euo pipefail - TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" - echo "::add-mask::$TOKEN" - FEED_AUTH_REGISTRY="${FEED_URL#https:}" - FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" - printf '%s\n' \ - "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ - "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" - - name: Publish tarball to internal feed - env: - DIST_TAG: ${{ github.event.inputs.dist-tag }} - run: | - set -euo pipefail - if [ "$FEED_URL" != "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/" ]; then - echo "::error::FEED_URL ('$FEED_URL') is not the expected internal feed. Refusing to publish." - exit 1 - fi - if [ "$DIST_TAG" = "unstable" ]; then - node nodejs/scripts/npm-release.js publish-manifest \ - dist/package-set-manifest.json dist unstable "$FEED_URL" azure - exit 0 - fi - shopt -s nullglob - TARBALLS=(./dist/*.tgz) - if [ "${#TARBALLS[@]}" -ne 9 ]; then - echo "::error::Expected nine Node.js package tarballs, found ${#TARBALLS[@]}." - exit 1 - fi - MAIN_TARBALL="" - for TARBALL in "${TARBALLS[@]}"; do - PACKAGE_NAME="$(tar -xOf "$TARBALL" package/package.json | jq -r .name)" - if [ "$PACKAGE_NAME" = "@github/copilot-sdk" ]; then - MAIN_TARBALL="$TARBALL" - continue - fi - node nodejs/scripts/npm-release.js publish \ - "$TARBALL" \ - "$DIST_TAG" \ - "$FEED_URL" \ - azure - done - if [ -z "$MAIN_TARBALL" ]; then - echo "::error::Main @github/copilot-sdk tarball not found." - exit 1 - fi - node nodejs/scripts/npm-release.js publish \ - "$MAIN_TARBALL" \ - "$DIST_TAG" \ - "$FEED_URL" \ - azure - - publish-dotnet: - name: Publish .NET SDK - if: inputs.dist-tag != 'unstable' - needs: version - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: ./dotnet - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: "10.0.x" - - name: Restore dependencies - run: dotnet restore - - name: Build and pack - run: dotnet pack src/GitHub.Copilot.SDK.csproj -c Release -p:Version=${{ needs.version.outputs.version }} -o ./artifacts - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: dotnet-package - path: | - dotnet/artifacts/*.nupkg - dotnet/artifacts/*.snupkg - - name: NuGet login (OIDC) - if: github.ref == 'refs/heads/main' - uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 - id: nuget-login - with: - # The following must be a username, not an organization name, and that user must have configured Trusted Publishing - # for this owner/repo/workflow combination in their NuGet.org account settings. We could set up a dedicated user for - # this purpose if needed, but then we'd have to manage that account separately. Other GitHub-owned packages on NuGet - # are associated with individual maintainers' accounts too. - user: stevesanderson - - name: Publish to NuGet - if: github.ref == 'refs/heads/main' - run: | - dotnet nuget push ./artifacts/*.nupkg --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate --no-symbols - dotnet nuget push ./artifacts/*.snupkg --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - - publish-dotnet-internal: - name: Publish .NET SDK to internal feed - needs: publish-dotnet - if: github.ref == 'refs/heads/main' - environment: cicd - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - id-token: write - env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/nuget/v3/index.json - steps: - - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: "10.0.x" - - name: Download .NET package - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: dotnet-package - path: ./dist - - name: Azure Login (OIDC -> id-cpd-ci) - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - client-id: "${{ vars.CPD_ID_CLIENT_ID }}" # id-cpd-ci - tenant-id: "${{ vars.CPD_ID_TENANT_ID }}" - allow-no-subscriptions: true - - name: Publish package to internal feed - run: | - set -euo pipefail - if [ "$FEED_URL" != "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/nuget/v3/index.json" ]; then - echo "::error::FEED_URL ('$FEED_URL') is not the expected internal feed. Refusing to publish." - exit 1 - fi - shopt -s nullglob - PACKAGES=(./dist/*.nupkg) - if [ "${#PACKAGES[@]}" -ne 1 ]; then - echo "::error::Expected one .NET package, found ${#PACKAGES[@]}." - exit 1 - fi - TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" - echo "::add-mask::$TOKEN" - dotnet nuget add source "$FEED_URL" --name CopilotInternal - # Keep the short-lived token out of NuGet.Config and command-line arguments. - export NuGetPackageSourceCredentials_CopilotInternal="Username=azure;Password=$TOKEN;ValidAuthenticationTypes=Basic" - # Azure Artifacts does not support .snupkg symbol packages. - dotnet nuget push "${PACKAGES[0]}" \ - --api-key AzureArtifacts \ - --source CopilotInternal \ - --skip-duplicate \ - --no-symbols - - publish-rust: - name: Publish Rust SDK - if: inputs.dist-tag != 'unstable' - needs: version - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./rust - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - with: - toolchain: "1.94.0" - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: "rust" - - name: Set version - run: sed -i -E 's/^version = ".*"$/version = "${{ needs.version.outputs.version }}"/' Cargo.toml - - name: Snapshot CLI version + hashes for build.rs - run: | - bash scripts/snapshot-bundled-cli-version.sh - bash scripts/snapshot-bundled-in-process-version.sh - - name: Verify CLI version snapshots exist - run: | - for snapshot in cli-version.txt cli-version-in-process.txt; do - if [[ ! -f "${snapshot}" ]]; then - echo "::error::${snapshot} was not generated. The Snapshot step must run before packaging." - exit 1 - fi - done - - name: Package (dry run) - run: cargo publish --dry-run --allow-dirty - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: rust-package - path: rust/target/package/*.crate - - name: Publish to crates.io - if: github.ref == 'refs/heads/main' - run: cargo publish --allow-dirty - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - publish-python: - name: Publish Python SDK - if: inputs.dist-tag != 'unstable' - needs: version - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: ./python - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.12" - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: "22.x" - - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - - name: Set version - run: sed -i "s/^version = .*/version = \"${{ needs.version.outputs.version }}\"/" pyproject.toml - - name: Inject CLI version - run: node scripts/inject-cli-version.mjs - - name: Build wheel - run: uv build --wheel --out-dir dist - - name: Upload artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: python-package - path: python/dist/* - - name: Publish to PyPI - if: github.ref == 'refs/heads/main' - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - packages-dir: python/dist/ - - publish-java: - name: Publish Java SDK - if: inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' - needs: version - permissions: - contents: read - uses: ./.github/workflows/java-publish-maven.yml - with: - releaseVersion: ${{ needs.version.outputs.version }} - sourceSha: ${{ github.sha }} - prerelease: ${{ github.event.inputs.dist-tag == 'prerelease' }} - secrets: inherit - - github-release: - name: Create GitHub Release - needs: - [ - version, - publish-nodejs, - publish-dotnet, - publish-python, - publish-rust, - publish-java, - ] - if: | - always() && - github.ref == 'refs/heads/main' && - inputs.dist-tag != 'unstable' && - needs.version.result == 'success' && - needs.publish-nodejs.result == 'success' && - needs.publish-dotnet.result == 'success' && - needs.publish-python.result == 'success' && - needs.publish-rust.result == 'success' && - needs.publish-java.outputs.mavenPublished == 'true' - runs-on: ubuntu-latest - permissions: - actions: write - contents: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Create GitHub Release - if: github.event.inputs.dist-tag == 'latest' - run: | - NOTES_FLAG="" - if git rev-parse "v${{ needs.version.outputs.current }}" >/dev/null 2>&1; then - NOTES_FLAG="--notes-start-tag v${{ needs.version.outputs.current }}" - fi - gh release create "v${{ needs.version.outputs.version }}" \ - --title "v${{ needs.version.outputs.version }}" \ - --generate-notes $NOTES_FLAG \ - --target ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create GitHub Pre-Release - if: github.event.inputs.dist-tag == 'prerelease' - run: | - NOTES_FLAG="" - if git rev-parse "v${{ needs.version.outputs.current-prerelease }}" >/dev/null 2>&1; then - NOTES_FLAG="--notes-start-tag v${{ needs.version.outputs.current-prerelease }}" - fi - gh release create "v${{ needs.version.outputs.version }}" \ - --prerelease \ - --title "v${{ needs.version.outputs.version }}" \ - --generate-notes $NOTES_FLAG \ - --target ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Trigger changelog generation - run: gh workflow run release-changelog.lock.yml -f tag="v${{ needs.version.outputs.version }}" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Tag Go SDK submodule - if: github.event.inputs.dist-tag == 'latest' || github.event.inputs.dist-tag == 'prerelease' - run: | - set -e - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch --tags - TAG_NAME="go/v${{ needs.version.outputs.version }}" - # Try to create the tag - will fail if it already exists - if git tag "$TAG_NAME" ${{ github.sha }} 2>/dev/null; then - git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git "$TAG_NAME" - echo "Created and pushed tag $TAG_NAME" - else - echo "Tag $TAG_NAME already exists, skipping" - fi - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Tag Rust SDK - # Keep a language-scoped source tag for traceability. Rust is - # included in the cross-language `vX.Y.Z` GitHub Release. - if: github.event.inputs.dist-tag == 'latest' || github.event.inputs.dist-tag == 'prerelease' - run: | - set -e - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch --tags - VERSION="${{ needs.version.outputs.version }}" - TAG_NAME="rust/v${VERSION}" - if git tag "$TAG_NAME" ${{ github.sha }} 2>/dev/null; then - git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git "$TAG_NAME" - echo "Created and pushed tag $TAG_NAME" - else - echo "Tag $TAG_NAME already exists, skipping tag push" - fi - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - tag-java: - name: Tag Java SDK - needs: [version, publish-java, github-release] - if: | - success() && - (github.event.inputs.dist-tag == 'latest' || - github.event.inputs.dist-tag == 'prerelease') - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.publish-java.outputs.sourceSha }} - fetch-depth: 0 - - - name: Tag Java SDK - # Reuse a tag only when it identifies the source that was published. - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch --tags origin - TAG_NAME="java/v${VERSION}" - if git show-ref --verify --quiet "refs/tags/$TAG_NAME"; then - TAG_COMMIT=$(git rev-parse --verify "refs/tags/${TAG_NAME}^{commit}") - if [ "$TAG_COMMIT" != "$SOURCE_SHA" ]; then - echo "::error::Tag $TAG_NAME points to $TAG_COMMIT, expected $SOURCE_SHA. Refusing to overwrite it." - exit 1 - fi - echo "Tag $TAG_NAME already points to the release source, skipping tag push" - else - STATUS=$? - if [ "$STATUS" -ne 1 ]; then - echo "::error::Could not inspect tag $TAG_NAME." - exit "$STATUS" - fi - git tag "$TAG_NAME" "$SOURCE_SHA" - git push origin "refs/tags/$TAG_NAME" - echo "Created and pushed tag $TAG_NAME" - fi - env: - VERSION: ${{ needs.version.outputs.version }} - SOURCE_SHA: ${{ needs.publish-java.outputs.sourceSha }} - - deploy-java-site: - name: Deploy Java documentation site - needs: [version, tag-java] - runs-on: ubuntu-latest - permissions: {} - steps: - - name: Trigger Java documentation site deploy - # A failed dispatch can be retried without recreating the GitHub release. - run: | - set -euo pipefail - TAG="java/v${VERSION}" - PUBLISH_AS_LATEST=true - if [ "$DIST_TAG" = "prerelease" ]; then - PUBLISH_AS_LATEST=false - fi - echo "Triggering site deployment for version ${VERSION} (tag: ${TAG})" - gh workflow run deploy-site.yml \ - --repo github/copilot-sdk-java \ - -f version="${VERSION}" \ - -f publish_as_latest="${PUBLISH_AS_LATEST}" \ - -f monorepo_tag="${TAG}" - env: - VERSION: ${{ needs.version.outputs.version }} - DIST_TAG: ${{ github.event.inputs.dist-tag }} - GITHUB_TOKEN: ${{ secrets.JAVA_RELEASE_GITHUB_TOKEN }} - runtime-plan: - name: Plan runtime release - needs: validate-dispatch - if: needs.validate-dispatch.outputs.kind == 'runtime' - runs-on: ubuntu-latest - environment: cicd - permissions: - actions: read - contents: read - outputs: - artifact_name: ${{ steps.plan.outputs.artifact_name }} - sdk_version: ${{ steps.plan.outputs.sdk_version }} - workflow_created_at: ${{ steps.plan.outputs.workflow_created_at }} - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Calculate the release identity - id: plan - working-directory: ./nodejs - env: - CHANNEL: ${{ inputs.dist-tag }} - GH_TOKEN: ${{ github.token }} - SDK_CHANNEL: ${{ inputs.dist-tag }} - SDK_SHA: ${{ github.sha }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - run: | - set -euo pipefail - WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | - jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" - export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" - export WORKFLOW_CREATED_AT - SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" - npm exec -- semver "$SDK_VERSION" >/dev/null - ARTIFACT_NAME="nodejs-${CHANNEL}-${SDK_VERSION}" - echo "Runtime E2E test policy: ${{ needs.validate-dispatch.outputs.test_policy }}" >> "$GITHUB_STEP_SUMMARY" - { - echo "artifact_name=$ARTIFACT_NAME" - echo "sdk_version=$SDK_VERSION" - echo "workflow_created_at=$WORKFLOW_CREATED_AT" - } >> "$GITHUB_OUTPUT" - - runtime-acquire: - name: Acquire runtime - needs: [validate-dispatch, runtime-plan] - runs-on: ubuntu-latest - environment: cicd - permissions: - contents: read - packages: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Configure authentication-only GitHub Packages access - env: - NODE_AUTH_TOKEN: ${{ github.token }} - run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" - - name: Download and validate all runtime platforms - env: - NODE_AUTH_TOKEN: ${{ github.token }} - RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }} - RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} - run: | - npm run acquire:runtime-packages -- \ - --version "$RUNTIME_VERSION" \ - --sha "$RUNTIME_SHA" \ - --output "$RUNNER_TEMP/runtime-packages" - - name: Archive validated runtime packages - run: tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" --exclude "runtime-packages/tarballs" -C "$RUNNER_TEMP" runtime-packages - - name: Upload validated runtime packages - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages.tar.gz - if-no-files-found: error - retention-days: 7 - - runtime-test: - name: Test runtime (${{ matrix.os }}, ${{ matrix.transport }}) - needs: [validate-dispatch, runtime-plan, runtime-acquire] - if: needs.validate-dispatch.outputs.test_policy != 'skipped' - permissions: - contents: read - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - transport: ["default", "inprocess"] - runs-on: ${{ matrix.os }} - environment: cicd - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Install test harness dependencies - working-directory: ./test/harness - run: npm ci --ignore-scripts - - name: Download validated runtime packages - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-package-artifact - - name: Extract validated runtime packages - run: | - runner_temp="$RUNNER_TEMP" - if command -v cygpath >/dev/null 2>&1; then - runner_temp="$(cygpath -u "$runner_temp")" - fi - rm -rf "$runner_temp/runtime-packages" - tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" - - name: Select the acquired runtime - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} - run: | - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - runtime_path="$(npm run --silent prepare:runtime -- --print-path)" - echo "COPILOT_SDK_RUNTIME_PACKAGE_DIR=$COPILOT_SDK_RUNTIME_PACKAGE_DIR" >> "$GITHUB_ENV" - echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - - run: npm run build - - name: Warm up PowerShell - if: runner.os == 'Windows' - run: pwsh.exe -Command "Write-Host 'PowerShell ready'" - - name: Select inprocess transport - if: matrix.transport == 'inprocess' - run: echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" - - name: Run Node SDK tests - id: e2e - continue-on-error: ${{ needs.validate-dispatch.outputs.test_policy == 'advisory' }} - env: - COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} - run: npm test - - name: Report advisory E2E failure - if: needs.validate-dispatch.outputs.test_policy == 'advisory' && steps.e2e.outcome == 'failure' - env: - RUNNER_OS: ${{ runner.os }} - run: | - echo "::warning::Runtime-backed Node SDK E2E tests failed on ${RUNNER_OS}; continuing because test-policy is advisory." - { - echo "### Advisory runtime E2E failure" - echo - echo "Runtime-backed Node SDK E2E tests failed on **${RUNNER_OS}**. Publication remains eligible because \`test-policy\` is \`advisory\`." - } >> "$GITHUB_STEP_SUMMARY" - - runtime-package: - name: Build SDK packages - needs: [validate-dispatch, runtime-plan, runtime-acquire, runtime-test] - if: | - always() && - !cancelled() && - needs.validate-dispatch.result == 'success' && - needs.runtime-plan.result == 'success' && - needs.runtime-acquire.result == 'success' && - ( - needs.runtime-test.result == 'success' || - (needs.validate-dispatch.outputs.test_policy == 'skipped' && needs.runtime-test.result == 'skipped') - ) - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Download validated runtime packages - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-package-artifact - - name: Extract validated runtime packages - run: | - runner_temp="$RUNNER_TEMP" - if command -v cygpath >/dev/null 2>&1; then - runner_temp="$(cygpath -u "$runner_temp")" - fi - rm -rf "$runner_temp/runtime-packages" - tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" - - name: Build and verify exact package set - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} - SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} - run: | - VERSION="$SDK_VERSION" node scripts/set-version.js - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts - npm run build - npm run pack:release - npm run verify:release-packages - - name: Create immutable release manifest - env: - RELEASE_CHANNEL: ${{ inputs.dist-tag }} - RUNTIME_RUN_ID: ${{ needs.validate-dispatch.outputs.runtime_run_id }} - RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }} - RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} - SDK_REF: ${{ github.ref }} - SDK_SHA: ${{ github.sha }} - SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} - TEST_POLICY: ${{ needs.validate-dispatch.outputs.test_policy }} - WORKFLOW_CREATED_AT: ${{ needs.runtime-plan.outputs.workflow_created_at }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - run: | - npm run release:manifest -- create release-manifest.json . - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ needs.runtime-plan.outputs.artifact_name }} - path: | - nodejs/release-manifest.json - nodejs/github-copilot-sdk-*.tgz - if-no-files-found: error - retention-days: 30 - - runtime-publish-internal: - name: Publish SDK internally - if: | - always() && - !cancelled() && - inputs.mode == 'publish' && - needs.runtime-plan.result == 'success' && - needs.runtime-package.result == 'success' - needs: [validate-dispatch, runtime-plan, runtime-package] - runs-on: ubuntu-latest - concurrency: - group: sdk-runtime-internal-${{ inputs.dist-tag }} - cancel-in-progress: false - queue: max - environment: cicd - permissions: - actions: read - contents: read - id-token: write - env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Download retained release - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: ${{ needs.runtime-plan.outputs.artifact_name }} - path: ./dist - - name: Validate retained release - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist - [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || - { echo "::error::Retained release belongs to a different workflow run."; exit 1; } - [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.dist-tag }}" ] || - { echo "::error::Retained release channel does not match the requested channel."; exit 1; } - - name: Azure login - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - run: | - TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" - echo "::add-mask::$TOKEN" - FEED_AUTH_REGISTRY="${FEED_URL#https:}" - FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" - printf '%s\n' \ - "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ - "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" - - name: Publish exact tarballs internally - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist "${{ inputs.dist-tag }}" "$FEED_URL" azure - - runtime-publish-public: - name: Publish SDK publicly - if: | - always() && - !cancelled() && - inputs.dist-tag == 'unstable' && - inputs.mode == 'publish' && - needs.runtime-plan.result == 'success' && - needs.runtime-publish-internal.result == 'success' - needs: [runtime-plan, runtime-publish-internal] - runs-on: ubuntu-latest - concurrency: - group: sdk-runtime-public-unstable - cancel-in-progress: false - queue: max - permissions: - actions: read - contents: read - id-token: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Update npm for trusted publishing - run: npm install -g npm@11.6.3 - - name: Download retained release - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: ${{ needs.runtime-plan.outputs.artifact_name }} - path: ./dist - - name: Validate retained release - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify \ - dist/release-manifest.json dist - - name: Publish the same tarballs to public npm - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist unstable https://registry.npmjs.org public diff --git a/.github/workflows/release-changelog.lock.yml b/.github/workflows/release-changelog.lock.yml index c66be2f35b..35e1ada2a3 100644 --- a/.github/workflows/release-changelog.lock.yml +++ b/.github/workflows/release-changelog.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6abb56e7d33f12df48df547a0f4143936049b45de28d4f1ff92bdc8020aa57c0","body_hash":"264021ebf1d0bc74660b753fafc82d43a8ef6c34311da38d757b91b959f8481b","compiler_version":"v0.88.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.80"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6abb56e7d33f12df48df547a0f4143936049b45de28d4f1ff92bdc8020aa57c0","body_hash":"74115979906c43791724a7a021f11f23493d9eb4e4867530ecd03555a0ef29d9","compiler_version":"v0.88.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.80"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"9271a1804551c0dc4fb0085a97979950aa2f8489","version":"v0.88.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12","digest":"sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.12@sha256:390051be4ed1847f774fd8980b61d3a3523574c0175d00c3fc7cdf2002a88202"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12","digest":"sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.12@sha256:d7d533d87c80d87ff91ac0e21e9299055c3beedff1536262b97ed700fb065a32"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12","digest":"sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.12@sha256:52c34aca98d2a6833c329f1505912a6949c4fda16618c010c979bd59ea99254f"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.15","digest":"sha256:60cd97533e93d8e7be36b979c0f08a70846189bda6190f28bbd6d427bc0d9b6e","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.15@sha256:60cd97533e93d8e7be36b979c0f08a70846189bda6190f28bbd6d427bc0d9b6e"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.11.0","digest":"sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699","pinned_image":"ghcr.io/github/github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699"}],"mcp_servers":[{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","issue_read","list_branches","list_commits","list_issue_types","list_issues","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_issues","search_pull_requests","search_repositories"]},{"name":"safeoutputs","tools":["missing_data","missing_tool","noop","update_release"]}]} # This file was automatically generated by gh-aw (v0.88.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/release-changelog.md b/.github/workflows/release-changelog.md index 6729eef47c..eb445e5dbc 100644 --- a/.github/workflows/release-changelog.md +++ b/.github/workflows/release-changelog.md @@ -37,6 +37,8 @@ Determine which type of release this is by inspecting the tag or fetching the re Use the GitHub API to fetch the release corresponding to `${{ github.event.inputs.tag }}` to get its name, publish date, prerelease status, and other metadata. +Use only this public SDK repository's source, history, releases, and pull requests. Do not retrieve or link private runtime source/history. If the target is not a published SDK `v` release, stop without updating anything; `runtime-*` releases contain acquisition assets, not SDK release notes. + ## Your Task ### Step 1: Identify the version range @@ -49,6 +51,7 @@ Use the GitHub API to fetch the release corresponding to `${{ github.event.input 2. The **new version** is the release tag: `${{ github.event.inputs.tag }}` 3. Fetch the release metadata to determine if this is a **stable** or **prerelease** release. 4. Determine the **previous version** to diff against: + - Consider only published, non-draft SDK `v` releases whose publication time precedes the target. Exclude `runtime-*` releases, even for prerelease comparisons. - **For stable releases**: list releases via the API and find the previous **stable** release (skip prereleases and the current release). Do not use the frozen `CHANGELOG.md` as the version baseline. Stable release notes include ALL changes since the last stable release, even if some were already mentioned in prerelease notes. - **For prerelease releases**: find the most recent release of **any kind** (stable or prerelease) that precedes this one. This way prerelease notes only cover what's new since the last release. 5. If no previous release exists at all, use the first commit in the repo as the starting point. diff --git a/.github/workflows/sdk-runtime-artifact.yml b/.github/workflows/sdk-runtime-artifact.yml index b2c8532f45..e1fa0cb929 100644 --- a/.github/workflows/sdk-runtime-artifact.yml +++ b/.github/workflows/sdk-runtime-artifact.yml @@ -70,6 +70,17 @@ jobs: with: persist-credentials: false + # Capture identity before caches, dependency setup, or downloaded addons + # can make the checkout appear dirty to metadata verification. + - uses: actions/setup-node@v6 + if: inputs.runtime-source == 'checkout' + with: + node-version-file: .nvmrc + + - name: Capture verified addon source identity + if: inputs.runtime-source == 'checkout' + run: node --experimental-strip-types script/resolve-cli-build-identity.ts --github-env + # Restore/save paths must match shared-cli-build.yml in the same order: # Actions includes the path list in the cache version, independently of the key. - name: Restore completed runtime build diff --git a/.github/workflows/sdk-rust.yml b/.github/workflows/sdk-rust.yml index ff875cda73..10b99a6beb 100644 --- a/.github/workflows/sdk-rust.yml +++ b/.github/workflows/sdk-rust.yml @@ -69,6 +69,8 @@ jobs: cache-bin: false - if: github.event_name != 'merge_group' && runner.os == 'Linux' run: cargo +nightly-2026-04-14 fmt --all -- --config-path .rustfmt.nightly.toml --check + - name: Verify release snapshot generation + run: node --test scripts/snapshot-version.test.mjs - if: github.event_name != 'merge_group' && runner.os == 'Linux' run: cargo clippy --all-targets --no-default-features --features test-support,local-runtime,derive -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type # Path-dependency consumers must not rerun build.rs on unchanged rebuilds. diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index eff4dbf75e..b4ceb876a4 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -70,6 +70,8 @@ jobs: with: persist-credentials: false + # Resolve identity before cache restoration and Bazel configuration. The + # composite setup below remains cache-miss-only because it also installs dependencies. - uses: actions/setup-node@v6 with: node-version-file: .nvmrc @@ -89,7 +91,8 @@ jobs: src/native/runtime/index.d.ts src/native/cli/cli-native.win32-x64-msvc.node src/native/cli/index.d.ts - key: windows-x64-addons-v1-release-windows-e2e-no-icf-${{ github.sha }} + # v2 evicts addons created before producer build identity was required. + key: windows-x64-addons-v2-release-windows-e2e-no-icf-${{ github.sha }} - uses: ./.github/actions/setup-node if: steps.cache.outputs.cache-hit != 'true' diff --git a/BUILD.bazel b/BUILD.bazel index f3a51c9a39..792fd2f519 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -5,6 +5,7 @@ SHARED_CODEGEN_INPUTS = [ "scripts/codegen/package.json", "scripts/codegen/utils.ts", "scripts/runtime-layout.mjs", + "scripts/runtime-release.mjs", ] HAND_WRITTEN_DOTNET_INPUTS = glob( @@ -222,6 +223,7 @@ genrule( "java/scripts/codegen/package.json", "nodejs/package.json", "scripts/runtime-layout.mjs", + "scripts/runtime-release.mjs", ], outs = ["projections/java.tar"], cmd = """ diff --git a/docs/developer-docs/secrets.md b/docs/developer-docs/secrets.md index ff7bd7d79c..1218f96254 100644 --- a/docs/developer-docs/secrets.md +++ b/docs/developer-docs/secrets.md @@ -1,16 +1,16 @@ # Secrets management -This document covers secrets management for the github/copilot-sdk repository. It lists the GitHub Actions secrets that maintainers must keep configured and not expired. +This document covers SDK build/test and automation secrets. Normal SDK package publishing runs from `github/copilot-agent-runtime` through its `publish.yml` entry workflow, using runtime-repository credentials and trusted publishers. Curated release notes and Java SNAPSHOT publishing run in the public SDK repository using its credentials. > [!WARNING] > If any of these secrets expire or are revoked, the corresponding workflows will fail silently or with opaque permission errors. Review this list periodically and rotate secrets before they expire. ## SDK test secrets -These secrets are used by the authoritative SDK build/test workflow and the publishing workflow. +These secrets are used by the authoritative SDK build/test workflow. * **`COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY`**: HMAC key used to authenticate with the Copilot Developer CLI integration endpoint during tests. Injected as `COPILOT_HMAC_KEY` in test environments. - * Workflows: `sdk.yml`, `publish.yml` + * Workflows: `sdk.yml` ## Agentic workflow secrets @@ -33,19 +33,21 @@ These secrets power the GitHub Agentic Workflows (gh-aw) used for issue triage, ## Java publishing secrets -These secrets support Java SDK Maven Central publishing, snapshot publishing, and post-release documentation deployment. +These secrets support Java SDK Maven Central publishing and post-release documentation deployment from the runtime repository. The SDK-side Java SNAPSHOT workflow uses `JAVA_MAVEN_CENTRAL_USERNAME` and `JAVA_MAVEN_CENTRAL_PASSWORD` in this repository. * **`JAVA_MAVEN_CENTRAL_USERNAME`**: Username generated by a Maven Central Portal user token. - * Workflows: `java-publish-maven.yml`, `java-publish-snapshot.yml` + * Runtime repository (`github/copilot-agent-runtime`): `publish.yml` and `sdk-publish-release.yml` + * SDK repository (`github/copilot-sdk`): `java-publish-snapshot.yml` * **`JAVA_MAVEN_CENTRAL_PASSWORD`**: Password or token for Maven Central (Sonatype OSSRH) authentication. - * Workflows: `java-publish-maven.yml`, `java-publish-snapshot.yml` + * Runtime repository (`github/copilot-agent-runtime`): `publish.yml` and `sdk-publish-release.yml` + * SDK repository (`github/copilot-sdk`): `java-publish-snapshot.yml` * **`JAVA_GPG_SECRET_KEY`**: GPG private key used to sign Java release artifacts for Maven Central. - * Workflows: `java-publish-maven.yml` + * Runtime workflow: `publish.yml` and its reusable SDK publishing jobs * **`JAVA_GPG_PASSPHRASE`**: Passphrase for the GPG signing key. - * Workflows: `java-publish-maven.yml` + * Runtime workflow: `publish.yml` and its reusable SDK publishing jobs * **`JAVA_RELEASE_GITHUB_TOKEN`**: GitHub token with **workflow dispatch** (actions:write) permission on `github/copilot-sdk-java`. Used to trigger the documentation site deployment after a release is published. * Workflows: `publish.yml` @@ -58,10 +60,11 @@ These secrets support Java SDK Maven Central publishing, snapshot publishing, an ## Secrets not managed in this repository * **`GITHUB_TOKEN`**: Automatically provided by GitHub Actions. No manual management required. - The runtime-driven Node SDK workflow grants it `packages: read` only while acquiring - private runtime packages from GitHub Packages. + SDK release packaging consumes runtime artifacts from the same workflow run + instead of acquiring private runtime packages from GitHub Packages. ## Further reading * [GitHub docs: Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) * [Repository secrets settings](https://github.com/github/copilot-sdk/settings/secrets/actions) (maintainer access required) +* [Runtime publishing configuration](https://github.com/github/copilot-agent-runtime/blob/main/docs/developer-docs/secrets.md#unified-publishing-configuration) (runtime repository access required) diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index 69fda41fba..302844b347 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/session-persistence.md @@ -131,6 +131,30 @@ await session.SendAndWaitAsync(new MessageOptions { Prompt = "Analyze my codebas Later—minutes, hours, or even days—you can resume the session from where you left off. +### Transcript recovery + +The resume option `allowTranscriptRecovery` controls recovery when the runtime loads a +transcript from storage. It defaults to `true` in all modes. With `false`, a load that +would discard damaged records or move `session.start` fails with JSON-RPC error `-32075` +without rewriting the transcript. An intact final record without a newline is accepted. + +Records skipped for forward compatibility still count when checking persisted order. +If such records precede `session.start`, recovery must move the start record ahead of +them. A transcript containing only skipped records is not empty and cannot be recovered +without a valid `session.start`. + +When recovery is allowed, the returned session exposes `transcriptRecovery`, including +the invalid line numbers, whether `session.start` moved, and a planned byte-exact backup +path. Loading alone does not write that backup; the next durable append performs the +repair and backup. + +> [!NOTE] +> This option applies to a new transcript load, not to the lifetime of a session. +> Reconnecting to a session already resident in the runtime reuses its live history +> without revalidating storage or rejecting recovery authorized by an earlier resume. +> The recovery report describes the load that performed recovery, not every subsequent +> reconnect. + ```mermaid flowchart LR subgraph Day1["Day 1"] diff --git a/dotnet/README.md b/dotnet/README.md index 654fc50816..e91656bf5a 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -225,6 +225,16 @@ 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. +- `AllowTranscriptRecovery` - Repairs a damaged transcript when true. The default + is true in all modes; set false to reject recovery. `session.TranscriptRecovery` contains + `PlannedBackupPath`, `InvalidLineNumbers` (including torn-tail loss), and + `SessionStartMoved` when repair is reported; otherwise it is null. On rejection, + `ResumeSessionAsync` throws an `IOException` whose `InnerException` is a + `RemoteRpcException`. Read `ErrorCode` (`-32075`) and `ErrorData` from that inner + exception for the server's typed error and its `invalidLineNumbers` / + `sessionStartMoved` fields. The outer message retains the existing communication-error + prefix. Disabling recovery still permits adding a missing newline after an intact + final record; it rejects torn tails. ```csharp await using var session = await client.CreateSessionAsync(new SessionConfig diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f43b32234c..e2da8747be 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1569,7 +1569,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes GitHubMcpToolConfig: config.GitHubMcpToolConfig, ManagedSettings: config.ManagedSettings, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null, - AdditionalDirectories: config.AdditionalDirectories); + AdditionalDirectories: config.AdditionalDirectories, + AllowTranscriptRecovery: config.AllowTranscriptRecovery); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -1580,6 +1581,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes sessionId); session.WorkspacePath = response.WorkspacePath; + session.TranscriptRecovery = response.TranscriptRecovery; session.SetCapabilities(response.Capabilities); session.SetOpenCanvases(response.OpenCanvases); @@ -3286,7 +3288,8 @@ internal record ResumeSessionRequest( [property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null, bool? EnableGitHubTelemetryForwarding = null, [property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null, - IList? AdditionalDirectories = null); + IList? AdditionalDirectories = null, + bool? AllowTranscriptRecovery = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( @@ -3294,7 +3297,8 @@ internal record ResumeSessionResponse( string? WorkspacePath, SessionCapabilities? Capabilities = null, #pragma warning disable GHCP001 - IList? OpenCanvases = null); + IList? OpenCanvases = null, + TranscriptRecoveryReport? TranscriptRecovery = null); #pragma warning restore GHCP001 internal record CommandWireDefinition( diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3f97448751..dc5e45849e 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -16363,6 +16363,14 @@ public sealed class ConnectorCapabilities /// Whether callers select a host-owned GitHub account through an opaque selection ID rather than supplying a provider token. [JsonPropertyName("opaqueAccountSelection")] public bool OpaqueAccountSelection { get; set; } + + /// Whether getAccount is supported. Absence means false. + [JsonPropertyName("sessionAccountSelection")] + public bool? SessionAccountSelection { get; set; } + + /// Whether reconcile accepts forceConnectorName. Absence means false. + [JsonPropertyName("targetedReconcile")] + public bool? TargetedReconcile { get; set; } } /// Identifies the target session. @@ -16374,6 +16382,45 @@ internal sealed class SessionConnectorsGetCapabilitiesRequest public string SessionId { get; set; } = string.Empty; } +/// Credential-free identity metadata. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class AuthIdentityMetadata +{ + /// Identity host. + [JsonPropertyName("host")] + public string Host { get; set; } = string.Empty; + + /// User login. + [JsonPropertyName("login")] + public string Login { get; set; } = string.Empty; + + /// Authentication type. + [JsonPropertyName("type")] + public AuthInfoType Type { get; set; } +} + +/// Session account selection. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class ConnectorSessionAccount +{ + /// Opaque session-scoped account selection ID. + [JsonPropertyName("accountId")] + public string AccountId { get; set; } = string.Empty; + + /// Credential-free identity metadata. + [JsonPropertyName("authInfo")] + public AuthIdentityMetadata AuthInfo { get => field ??= new(); set; } +} + +/// Identifies the target session. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class SessionConnectorsGetAccountRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Account-targeted authorization update required by the Connector service. The account ID is an opaque host routing identifier; no credential is included. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class ConnectorAuthorizationRequirement @@ -16399,10 +16446,18 @@ public sealed class ConnectorCatalogEntry [JsonPropertyName("displayName")] public string DisplayName { get; set; } = string.Empty; + /// Optional catalog logo. + [JsonPropertyName("logo")] + public string? Logo { get; set; } + /// Canonical Connector name used by lifecycle methods. [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + /// Optional catalog release tag. + [JsonPropertyName("releaseTag")] + public string? ReleaseTag { get; set; } + /// Opaque stable runtime IDs currently projected into the session for this Connector. [JsonPropertyName("runtimeServerIds")] public IList RuntimeServerIds { get => field ??= []; set; } @@ -16410,6 +16465,10 @@ public sealed class ConnectorCatalogEntry /// Current authoritative service connection state. [JsonPropertyName("status")] public ConnectorCatalogStatus Status { get; set; } + + /// Optional catalog tier. + [JsonPropertyName("tier")] + public string? Tier { get; set; } } /// Validated Connector catalog snapshot cached by the session. @@ -16621,12 +16680,33 @@ public sealed class ConnectorDisconnectResult /// Requests authoritative Connector-to-MCP reconciliation for the pinned account. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] -internal sealed class ConnectorReconcileRequest +public sealed class ConnectorReconcileRequest +{ + /// Opaque account selection ID. It must match the account already pinned to the session, if any. + [JsonPropertyName("accountId")] + public required string AccountId { get; set; } + + /// Optional Connector name to reinitialize. Requires the targetedReconcile capability. + [JsonPropertyName("forceConnectorName")] + public string? ForceConnectorName { get; set; } + + /// When true, refresh the catalog before reconciling. A disabled Connector API performs no service request. + [JsonPropertyName("refreshCatalog")] + public bool? RefreshCatalog { get; set; } +} + +/// Requests authoritative Connector-to-MCP reconciliation for the pinned account. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class ConnectorReconcileRequestWithSession { /// Opaque account selection ID. It must match the account already pinned to the session, if any. [JsonPropertyName("accountId")] public string AccountId { get; set; } = string.Empty; + /// Optional Connector name to reinitialize. Requires the targetedReconcile capability. + [JsonPropertyName("forceConnectorName")] + public string? ForceConnectorName { get; set; } + /// When true, refresh the catalog before reconciling. A disabled Connector API performs no service request. [JsonPropertyName("refreshCatalog")] public bool? RefreshCatalog { get; set; } @@ -45531,6 +45611,17 @@ public async Task GetCapabilitiesAsync(CancellationToken return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.connectors.getCapabilities", [request], cancellationToken); } + /// Returns the session account selection, or null. + /// The to monitor for cancellation requests. The default is . + /// Session account selection, or null. + public async Task GetAccountAsync(CancellationToken cancellationToken = default) + { + _session.ThrowIfDisposed(); + + var request = new SessionConnectorsGetAccountRequest { SessionId = _session.SessionId }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.connectors.getAccount", [request], cancellationToken); + } + /// Returns authoritative session Connector state from current availability, pinned account selection, cached catalog, and live MCP projection without performing a Connector service request. /// The to monitor for cancellation requests. The default is . /// Authoritative session connector state. Account IDs are opaque routing identifiers and credentials are never included. @@ -45639,10 +45730,23 @@ public async Task ReconcileAsync(string accountId, bool? refres ArgumentNullException.ThrowIfNull(accountId); _session.ThrowIfDisposed(); - var request = new ConnectorReconcileRequest { SessionId = _session.SessionId, AccountId = accountId, RefreshCatalog = refreshCatalog }; + var request = new ConnectorReconcileRequestWithSession { SessionId = _session.SessionId, AccountId = accountId, RefreshCatalog = refreshCatalog }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.connectors.reconcile", [request], cancellationToken); } + /// Reconciles the authoritative cached or freshly requested Connector catalog into the session Connector MCP projection and returns live status. + /// Requests authoritative Connector-to-MCP reconciliation for the pinned account. + /// The to monitor for cancellation requests. The default is . + /// Authoritative session connector state. Account IDs are opaque routing identifiers and credentials are never included. + public async Task ReconcileAsync(ConnectorReconcileRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.AccountId); + _session.ThrowIfDisposed(); + var wireRequest = new ConnectorReconcileRequestWithSession { SessionId = _session.SessionId, AccountId = request.AccountId, RefreshCatalog = request.RefreshCatalog, ForceConnectorName = request.ForceConnectorName }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.connectors.reconcile", [wireRequest], cancellationToken); + } + /// Reconciles the authoritative Connector catalog into the session MCP projection during startup with a bounded deadline and fail-closed cleanup. /// Opaque account selection ID previously returned by an account discovery API. /// The to monitor for cancellation requests. The default is . @@ -48817,6 +48921,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(AuthEnumerateQuery))] [JsonSerializable(typeof(AuthEnumerateValue))] [JsonSerializable(typeof(AuthIdentity))] +[JsonSerializable(typeof(AuthIdentityMetadata))] [JsonSerializable(typeof(AuthInfo))] [JsonSerializable(typeof(AuthLoginAdvanceRequest))] [JsonSerializable(typeof(AuthLoginBeginRequest))] @@ -48904,7 +49009,9 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(ConnectorContinueRequest))] [JsonSerializable(typeof(ConnectorDisconnectResult))] [JsonSerializable(typeof(ConnectorReconcileRequest))] +[JsonSerializable(typeof(ConnectorReconcileRequestWithSession))] [JsonSerializable(typeof(ConnectorRuntimeStatus))] +[JsonSerializable(typeof(ConnectorSessionAccount))] [JsonSerializable(typeof(ConnectorStatus))] [JsonSerializable(typeof(ContentExclusionCheckPathsRequest))] [JsonSerializable(typeof(ContentExclusionCheckPathsResult))] @@ -49485,6 +49592,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(SessionCommandsListRequestWithSession))] [JsonSerializable(typeof(SessionCompletionItem))] [JsonSerializable(typeof(SessionCompletionsGetTriggerCharactersRequest))] +[JsonSerializable(typeof(SessionConnectorsGetAccountRequest))] [JsonSerializable(typeof(SessionConnectorsGetCapabilitiesRequest))] [JsonSerializable(typeof(SessionConnectorsGetStatusRequest))] [JsonSerializable(typeof(SessionConnectorsWithdrawProjectionRequest))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 11c9635f42..29cd2d23fa 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -6695,7 +6695,7 @@ public sealed partial class SessionMcpServersLoadedData /// Payload of `session.mcp_server_status_changed` for one MCP server's status and optional failure error. public sealed partial class SessionMcpServerStatusChangedData { - /// Runtime configuration provenance for a failed connection, or unknown when unavailable. Additional string values may be introduced. + /// Runtime configuration provenance for a connected or failed server, or unknown when unavailable. Additional string values may be introduced. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("configSource")] public string? ConfigSource { get; set; } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index ce50859571..acdd434b84 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -125,6 +125,9 @@ private sealed record EventSubscription(Type EventType, Action Han /// public string? WorkspacePath { get; internal set; } + /// Details of transcript repair reported by session.resume, if any. + public TranscriptRecoveryReport? TranscriptRecovery { get; internal set; } + /// /// Gets the capabilities reported by the host for this session. /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index f40a71b95e..f1f85d1206 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -4044,6 +4044,15 @@ private SessionConfig(SessionConfig? other) : base(other) public SessionConfig Clone() => new(this); } +/// Details of a transcript repair performed while resuming a session. +/// Path planned for the original transcript backup. +/// Invalid or torn-tail lines affected by repair. +/// Whether the session start event was relocated. +public sealed record TranscriptRecoveryReport( + string PlannedBackupPath, + IList InvalidLineNumbers, + bool SessionStartMoved); + /// /// Configuration options for resuming an existing Copilot session. /// @@ -4062,6 +4071,7 @@ private ResumeSessionConfig(ResumeSessionConfig? other) : base(other) SuppressResumeEvent = other.SuppressResumeEvent; ContinuePendingWork = other.ContinuePendingWork; + AllowTranscriptRecovery = other.AllowTranscriptRecovery; OpenCanvases = other.OpenCanvases is not null ? [.. other.OpenCanvases] : null; } @@ -4075,7 +4085,8 @@ private ResumeSessionConfig(ResumeSessionConfig? other) : base(other) /// When , instructs the runtime to continue any tool calls /// or permission prompts that were still pending when the session was last suspended. /// When (the default), the runtime treats pending work as - /// interrupted on resume. + /// interrupted on resume. Completed tool results already durably recorded + /// by the runtime are preserved. /// /// For permission requests, the runtime re-emits permission.requested so the /// registered handler can re-prompt; @@ -4085,6 +4096,13 @@ private ResumeSessionConfig(ResumeSessionConfig? other) : base(other) /// public bool? ContinuePendingWork { get; set; } + /// + /// Whether to repair a damaged transcript on resume. Defaults to true in + /// all modes. Set false to reject recovery. Recovery can discard a torn tail; + /// inspect after resume. + /// + public bool? AllowTranscriptRecovery { get; set; } + #pragma warning disable GHCP001 /// /// Snapshot of canvases that were already open when the session was suspended. diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index 528901d290..0c9bfbaf5a 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -58,7 +58,6 @@ COPILOT_CLI_DOWNLOAD_BASE_URL is also honored. --> $(COPILOT_CLI_DOWNLOAD_BASE_URL) - https://github.com/github/copilot-cli/releases/download + # GitHub Copilot CLI SDK for Rust A Rust SDK for programmatic access to the GitHub Copilot CLI. @@ -101,6 +103,13 @@ let pong = client.ping("hello").await?; client.stop().await?; ``` +`ResumeSessionConfig::with_allow_transcript_recovery(false)` rejects a resume that would +discard or reorder transcript records, but permits adding a missing newline after +an intact final record. Recovery defaults to `true` in all modes. +When allowed and performed, `session.transcript_recovery()` returns +the planned backup path, invalid line numbers, and whether `session.start` was moved; +the backup is written on the next append rather than during resume. + After `Client::start` succeeds, inspect its startup cost without parsing logs: ```rust,ignore @@ -1451,7 +1460,21 @@ explicit program path is supplied. (present in published crate tarballs and vendored slots). - Otherwise, `../nodejs/package.json` (contributor build inside the github/copilot-sdk repo). - When SDK-managed acquisition is enabled, the resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION`. The runtime resolver consumes it to recompute the on-disk path by convention, so no absolute paths leak into the rlib. + When SDK-managed acquisition is enabled, the resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION`. A release-scoped `COPILOT_SDK_CLI_CACHE_ID` lets the runtime resolver recompute the on-disk path without leaking absolute build-machine paths into the rlib. + + Stable/prerelease snapshots, including existing published crates, acquire + assets from `github/copilot-cli` at `v`. Public unstable + snapshots additionally pin + `release-url=https://github.com/github/copilot-sdk/releases/download/runtime-`. + Both snapshots must agree on the version and release URL. Executables, + runtime packages, and checksum lookups all use that exact public release; + consumers need no credentials or unstable-specific environment settings. + + Source checkouts without snapshots infer the SDK-hosted release for canonical + unstable pins in `nodejs/package.json`, including both + `X.Y.Z-unstable.r.g` and `X.Y.Z-N.unstable.r.g`. + Other source pins and legacy snapshots without `release-url` continue to use + the CLI release location. 2. **Build time:** `build.rs` downloads the platform-specific full CLI archive and runtime package, then verifies both SHA-256 hashes against the release's @@ -1484,6 +1507,10 @@ explicit program path is supplied. application. Old version directories accumulate in siblings; clean them up at your leisure. + Public unstable cache directories use + `copilot-sdk-runtime-` instead of `` to keep release + destinations separate. Legacy cache paths remain unchanged. + ### Overriding the extraction location [`ClientOptions::with_bundled_cli_extract_dir`] redirects embed-mode extraction to a custom directory (CI runners with ephemeral homes, sandboxes that disallow cache paths, etc.): @@ -1575,9 +1602,61 @@ In embed mode `build.rs` downloads both verified archives on every clean build by default. Set `BUNDLED_CLI_CACHE_DIR=` to cache them between builds (CI keys this on `-` for near-zero-cost rebuilds on cache hits). For Copilot CLI 1.0.83-5, the two upstream archives total roughly 132-157 MB per -platform before the runtime package is filtered. With `runtime` enabled and both -`bundled-cli` and `local-runtime` disabled, -there is no separate archive cache: the extracted runtime bundle is the cache. +platform before the runtime package is filtered. With `runtime` enabled and +both `bundled-cli` and `local-runtime` disabled, +the extracted runtime bundle is the primary cache; a configured download +cache can also supply its initial extraction. + +### Preparing release snapshots before publication + +The two scripts in `scripts/` retain their no-option behavior: read +`../nodejs/package.json` and fetch the pinned CLI release's `SHA256SUMS.txt`. +Release packaging can instead supply local checksums and the final public +location, without waiting for that release to exist: + +```bash +bash scripts/snapshot-bundled-cli-version.sh \ + --version "$RUNTIME_VERSION" --release-url "$RELEASE_URL" \ + --checksums "$LOCAL_SHA256SUMS" +bash scripts/snapshot-bundled-in-process-version.sh \ + --version "$RUNTIME_VERSION" --release-url "$RELEASE_URL" \ + --checksums "$LOCAL_SHA256SUMS" +``` + +`RELEASE_URL` is the exact base URL described above, without a trailing slash. +`LOCAL_SHA256SUMS` names the staged checksum file covering all eight executable +archives and all eight `github-copilot--.tgz` payloads. +The existing `cli-version.txt` and `cli-version-in-process.txt` package entries +carry the version, hashes, and optional `release-url`; no separate manifest or +consumer configuration is required. + +For normal promotions from an older compatible source, invoke the reviewed +producer scripts with `--output` pointing to each snapshot in the selected +Rust source staging directory. Pass the selected runtime version explicitly. +This preserves the older product's build code and does not require its source +to contain these producer scripts. Public unstable releases still require +selected-source support for the SDK-hosted acquisition location. + +For offline build/package verification, seed `BUNDLED_CLI_CACHE_DIR` with the +host's executable and payload archives under these filenames: + +* CLI release: `v-` +* SDK-hosted unstable release: `copilot-sdk-runtime--` + +Archive bytes must match the snapshot hashes; cache hits are verified and +corrupt entries are evicted. The executable is `copilot-.tar.gz` (or +`.zip` on Windows); the payload is +`github-copilot--.tgz`. A non-bundled build needs only +the payload archive and can also use this seeded cache for initial extraction. +Keep `COPILOT_SKIP_CLI_DOWNLOAD` unset during acquisition verification. + +The focused acquisition checks use tiny local archive fixtures and never +download a runtime: + +```bash +node --test scripts/snapshot-version.test.mjs +cargo test --no-default-features --features local-runtime --test build_acquisition +``` ### Platforms diff --git a/rust/RELEASING.md b/rust/RELEASING.md index 06e362f54e..084cd4f887 100644 --- a/rust/RELEASING.md +++ b/rust/RELEASING.md @@ -1,92 +1,58 @@ -# Releasing `github-copilot-sdk` + -The Rust crate ships through the unified `publish.yml` workflow -alongside the other SDKs. There is no Rust-specific release workflow. +# Rust SDK releases -## TL;DR +The [`github-copilot-sdk` crate](https://crates.io/crates/github-copilot-sdk) +is published alongside the Node.js, Python, Go, .NET, and Java SDKs. SDK and +runtime versions are numbered independently; each published crate pins the +runtime version it uses. -1. Land your changes on `main`. -2. Trigger the **Publish SDK packages** workflow - (`.github/workflows/publish.yml`) via `workflow_dispatch`. -3. Pick `dist-tag`: - - `latest` — stable release (e.g. `1.0.0`). - - `prerelease` — beta release (e.g. `1.0.0-beta.4`). Lands on - crates.io as a prerelease; users must opt in with an explicit - prerelease version requirement to install it. - - `unstable` — skipped for Rust (Cargo doesn't have a clean - equivalent of npm's `unstable` dist-tag). -4. For `latest` and `prerelease`, the workflow publishes all SDKs at - the shared computed version, tags `rust/vX.Y.Z` for source - traceability, and creates one combined `vX.Y.Z` GitHub Release. - The `unstable` channel publishes only the Node.js SDK and does not - create a GitHub Release. +## Release channels -## Version, tag, and release notes +| Channel | Rust publication | +| --- | --- | +| Stable | Stable crate, released with all six SDKs and the CLI | +| Prerelease | Prerelease crate, released with all six SDKs and the CLI | +| Unstable | Development crate, released with all six SDKs and public runtime acquisition assets | -- **Crate version:** the in-tree `rust/Cargo.toml` carries `0.0.0-dev` - as a placeholder. CI overrides it at publish time with the version - computed by `publish.yml` (or an explicit `version` workflow input). -- **Tag:** `rust/vX.Y.Z` (matches the `go/vX.Y.Z` style used elsewhere - in this repo). The tag identifies the source used for that crate - version. -- **Release notes:** generated for the combined `vX.Y.Z` GitHub - Release. Write descriptive PR titles for changes that touch the Rust - surface so they are represented accurately in the shared notes. +Find available crate versions on +[crates.io](https://crates.io/crates/github-copilot-sdk/versions). -## Cargo prerelease semantics +## Runtime pins and release outputs -`cargo add github-copilot-sdk` and `version = "1"` requirements skip -prereleases by default. Users who want to opt in to a beta must -write an explicit prerelease requirement: +Published crates include `cli-version.txt` and `cli-version-in-process.txt` +with the exact runtime version, release location, and archive hashes. -```toml -github-copilot-sdk = "1.0.0-beta.4" -``` +Stable/prerelease acquisition uses +[`github/copilot-cli` releases](https://github.com/github/copilot-cli/releases). +Unstable acquisition uses the exact `runtime-` release in +[`github/copilot-sdk`](https://github.com/github/copilot-sdk/releases). +Default consumer builds acquire both +the runtime platform package and standalone CLI archive without private-feed +credentials. -This matches Cargo's standard semver behavior and means a -prerelease-channel publish won't surprise stable users. +The `rust/v` and `v` tags identify the corresponding +public SDK source snapshot for stable, prerelease, and unstable versions. +Stable/prerelease versions also have a combined `v` GitHub release. +Unstable source tags do not advance SDK `main` or create an SDK GitHub release +announcement. Their runtime assets remain available under the separate +`runtime-` release. -## Yanking a release +## Cargo prerelease semantics -If a published version contains a critical bug, yank it from -crates.io to prevent new installs: +Cargo does not have npm distribution tags. Consumers opt into a prerelease +or unstable crate by explicitly requesting its version, for example: -```sh -cargo yank --version X.Y.Z github-copilot-sdk +```toml +github-copilot-sdk = "=1.0.0-unstable.123456.gabcdef0" ``` -Yanking does *not* delete the version — existing `Cargo.lock` files -keep working — but it stops new resolutions from picking it. Follow -up with a patch release that fixes the bug, and update the combined -GitHub Release notes to explain why. - -Reverse with `cargo yank --undo --version X.Y.Z github-copilot-sdk` -if the yank was a mistake. - -## Manual publish (emergency only) +This is an illustrative version; select an available version from crates.io. +Stable requirements do not automatically select prereleases. -If GitHub Actions is unavailable, a maintainer with crates.io -credentials can publish locally: - -```sh -cd rust - -# Set the real version (replace X.Y.Z). -perl -i -pe 's/^version = ".*"$/version = "X.Y.Z"/' Cargo.toml - -# Verify package contents. -cargo publish --dry-run - -# Publish for real. -cargo publish - -# Tag and push. -git tag rust/vX.Y.Z -git push origin rust/vX.Y.Z - -# Restore the placeholder. -perl -i -pe 's/^version = ".*"$/version = "0.0.0-dev"/' Cargo.toml -``` +## Yanked versions -Manual publishes skip the combined GitHub Release. Create or update the -matching `vX.Y.Z` release after pushing the tag. +A crate version with a critical defect can be yanked. Yanking does not delete +the version or invalidate existing lockfiles, but Cargo excludes it from new +dependency resolutions. Consult the release notes and update affected +applications to a fixed version. diff --git a/rust/build/in_process.rs b/rust/build/in_process.rs index a4bafc35af..18be31f127 100644 --- a/rust/build/in_process.rs +++ b/rust/build/in_process.rs @@ -1,4 +1,6 @@ -use std::io::{Read, Write}; +// Copyright (c) Microsoft Corporation. All rights reserved. + +use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -87,36 +89,35 @@ pub(crate) fn main() { // consumer; generated by the publish workflow from SHA256SUMS.txt). // 2. Sibling `../nodejs/package.json` plus the release SHA256SUMS.txt // (contributor build inside the github/copilot-sdk repo). - let (version, local_expected_hash) = resolve_version_and_optional_hash(platform.package_name); - - // Bake the version into the crate regardless of mode. This is the - // single source of truth for "what CLI version did build.rs target", - // consumed by both the embed-mode path computation in embeddedcli.rs - // and the runtime path computation in resolve.rs (when `bundled-cli` - // is off). It's a small, machine-independent datum: no absolute - // paths, no username/home leakage, so sccache / cross-machine - // `target/` reuse stays cache-coherent. + let (release, local_expected_hash) = + resolve_version_and_optional_hash(&manifest_dir, platform.package_name); + let version = &release.version; + + // Keep diagnostics on the actual version, and cache paths on a + // release-scoped identity. Neither exposes build-machine paths. println!("cargo:rustc-env=COPILOT_SDK_CLI_VERSION={version}"); + let cache_identity = release.cache_identity(); + println!("cargo:rustc-env=COPILOT_SDK_CLI_CACHE_ID={cache_identity}"); let asset_platform = platform .package_name .strip_prefix("copilot-") .expect("platform package names start with copilot-"); let archive_name = format!("github-copilot-{version}-{asset_platform}.tgz"); - let download_url = format!( - "https://github.com/github/copilot-cli/releases/download/v{version}/{archive_name}" - ); + let download_url = release.asset_url(&archive_name); let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR") .ok() .map(std::path::PathBuf::from); - let cache_key = format!("v{version}-{archive_name}"); + let cache_key = release.cache_key(&archive_name); let include_runtime = std::env::var_os("CARGO_FEATURE_IN_PROCESS").is_some(); if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { + let cli_asset_name = platform.cli_asset_name(); + let cli_expected_hash = resolve_cli_hash(&release, &cli_asset_name); let runtime_expected_hash = local_expected_hash .clone() - .unwrap_or_else(|| fetch_in_process_release_hash(&version, platform.package_name)); + .unwrap_or_else(|| fetch_release_hash(&release, &archive_name)); let runtime_package = cached_download( &download_url, &cache_key, @@ -125,13 +126,9 @@ pub(crate) fn main() { ); verify_runtime_package(&runtime_package, platform, &archive_name); - let cli_asset_name = platform.cli_asset_name(); - let cli_expected_hash = resolve_cli_hash(&version, &cli_asset_name); let cli_archive = cached_download( - &format!( - "https://github.com/github/copilot-cli/releases/download/v{version}/{cli_asset_name}" - ), - &format!("v{version}-{cli_asset_name}"), + &release.asset_url(&cli_asset_name), + &release.cache_key(&cli_asset_name), &cli_expected_hash, &cache_dir, ); @@ -151,10 +148,10 @@ pub(crate) fn main() { // Skip the upstream download entirely when both files already exist. // // Runtime resolution (see `src/resolve.rs::extracted_program`) - // recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the + // recomputes this same path from `COPILOT_SDK_CLI_CACHE_ID` + the // OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`, // so we don't bake an absolute path into the crate. - let install_dir = cache_paths::extracted_runtime_install_dir(&version); + let install_dir = cache_paths::extracted_runtime_install_dir(&cache_identity); let required_paths = [ install_dir.join(platform.runtime_wrapper_name()), install_dir.join("runtime.node"), @@ -177,11 +174,11 @@ pub(crate) fn main() { } None => marker .as_deref() - .is_some_and(|contents| marker_matches_version(contents, &version)), + .is_some_and(|contents| marker_matches_version(contents, version)), }; if !cache_is_current { - let expected_hash = local_expected_hash - .unwrap_or_else(|| fetch_in_process_release_hash(&version, platform.package_name)); + let expected_hash = + local_expected_hash.unwrap_or_else(|| fetch_release_hash(&release, &archive_name)); let expected_marker = format!("{version}\n{expected_hash}\n"); if install_dir.exists() { std::fs::remove_dir_all(&install_dir).unwrap_or_else(|e| { @@ -378,28 +375,28 @@ fn append_archive_file( /// Resolve the CLI version and any locally snapshotted release hash for the /// current target's platform package. Contributor builds defer fetching the /// checksum until a download is actually required. -fn resolve_version_and_optional_hash(package_name: &str) -> (String, Option) { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); - +fn resolve_version_and_optional_hash( + manifest_dir: &Path, + package_name: &str, +) -> (Release, Option) { // 1. Snapshot file at the crate root (published-crate consumer, // vendored-slot consumer). Combined version + per-asset hashes. - let snapshot = Path::new(&manifest_dir).join("cli-version-in-process.txt"); + let snapshot = manifest_dir.join("cli-version-in-process.txt"); if snapshot.is_file() { let contents = std::fs::read_to_string(&snapshot) .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); - let (version, hash) = parse_snapshot(&contents, package_name) + let (release, hash) = parse_snapshot(&contents, package_name) .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); - return (version, Some(hash)); + return (release, Some(hash)); } // 2. Package version plus release checksums (contributor build). - let package_json = Path::new(&manifest_dir) - .join("..") - .join("nodejs") - .join("package.json"); + let package_json = manifest_dir.join("..").join("nodejs").join("package.json"); if package_json.is_file() { let version = read_version_from_package_json(&package_json); - return (version, None); + let release = Release::from_source_version(version) + .unwrap_or_else(|e| panic!("invalid {}: {e}", package_json.display())); + return (release, None); } panic!( @@ -414,31 +411,23 @@ fn resolve_version_and_optional_hash(package_name: &str) -> (String, Option String { - let platform = package_name - .strip_prefix("copilot-") - .expect("platform package names start with copilot-"); - let asset_name = format!("github-copilot-{version}-{platform}.tgz"); - fetch_release_hash(version, &asset_name) -} - -fn resolve_cli_hash(version: &str, asset_name: &str) -> String { +fn resolve_cli_hash(release: &Release, asset_name: &str) -> String { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); let snapshot = Path::new(&manifest_dir).join("cli-version.txt"); if snapshot.is_file() { let contents = std::fs::read_to_string(&snapshot) .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); - let (snapshot_version, hash) = parse_snapshot(&contents, asset_name) + let (snapshot_release, hash) = parse_snapshot(&contents, asset_name) .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); assert_eq!( - snapshot_version, - version, - "{} and the selected runtime version source must pin the same version", + snapshot_release, + *release, + "{} and the selected runtime version source must pin the same version and release URL", snapshot.display() ); return hash; } - fetch_release_hash(version, asset_name) + fetch_release_hash(release, asset_name) } fn marker_matches_version(contents: &str, version: &str) -> bool { @@ -450,33 +439,156 @@ fn marker_matches_version(contents: &str, version: &str) -> bool { && lines.next().is_none() } -/// Parse the `cli-version-in-process.txt` snapshot file. Format is one `key=value` per -/// line. The first non-comment line is `version=X.Y.Z`; subsequent lines map -/// platform package name to SHA-256. Blank lines and lines starting with `#` -/// are skipped. -fn parse_snapshot(contents: &str, package_name: &str) -> Result<(String, String), String> { - let mut version: Option = None; - let mut hash: Option = None; +#[derive(Debug, PartialEq, Eq)] +struct Release { + version: String, + sdk_release: bool, +} + +impl Release { + /// Infer the host only for source checkouts; URL-less snapshots retain their legacy host. + fn from_source_version(version: String) -> io::Result { + let mut release = Self::new(version, None)?; + let Some((core, suffix)) = release.version.split_once('-') else { + return Ok(release); + }; + let numeric = |part: &str| { + !part.is_empty() + && (part == "0" || !part.starts_with('0')) + && part.bytes().all(|byte| byte.is_ascii_digit()) + }; + let mut components = core.split('.'); + let canonical_core = + (0..3).all(|_| components.next().is_some_and(numeric)) && components.next().is_none(); + let identity = suffix.strip_prefix("unstable.r").or_else(|| { + let (prerelease, identity) = suffix.split_once(".unstable.r")?; + numeric(prerelease).then_some(identity) + }); + release.sdk_release = canonical_core + && identity.is_some_and(|identity| { + let Some((run, sha)) = identity.split_once(".g") else { + return false; + }; + numeric(run) + && run != "0" + && sha.len() == 7 + && sha + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }); + Ok(release) + } + + fn new(version: String, release_url: Option<&str>) -> io::Result { + if !version.starts_with(|c: char| c.is_ascii_digit()) + || !version + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b".+-".contains(&c)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid release version", + )); + } + let cli_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); + let sdk_url = + format!("https://github.com/github/copilot-sdk/releases/download/runtime-{version}"); + let sdk_release = match release_url { + None => false, + Some(url) if url == cli_url => false, + Some(url) if url == sdk_url => true, + Some(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "release-url must be the exact public copilot-cli/v or copilot-sdk/runtime- release URL", + )); + } + }; + Ok(Self { + version, + sdk_release, + }) + } + + fn asset_url(&self, asset_name: &str) -> String { + if self.sdk_release { + format!( + "https://github.com/github/copilot-sdk/releases/download/runtime-{}/{asset_name}", + self.version + ) + } else { + format!( + "https://github.com/github/copilot-cli/releases/download/v{}/{asset_name}", + self.version + ) + } + } + + fn cache_identity(&self) -> String { + if self.sdk_release { + format!("copilot-sdk-runtime-{}", self.version) + } else { + self.version.clone() + } + } + + fn cache_key(&self, asset_name: &str) -> String { + if self.sdk_release { + format!("{}-{asset_name}", self.cache_identity()) + } else { + format!("v{}-{asset_name}", self.version) + } + } +} + +/// Both publish snapshots use version/hash entries plus an optional exact release URL. +/// Snapshots without a URL retain the original public CLI acquisition contract. +fn parse_snapshot(contents: &str, asset_name: &str) -> io::Result<(Release, String)> { + let mut version = None; + let mut release_url = None; + let mut hash = None; for (line_no, raw) in contents.lines().enumerate() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = line.split_once('=') else { - return Err(format!( - "line {}: expected `key=value`, got `{raw}`", - line_no + 1 + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("line {}: expected `key=value`, got `{raw}`", line_no + 1), )); }; - match key.trim() { - "version" => version = Some(value.trim().to_string()), - k if k == package_name => hash = Some(value.trim().to_string()), - _ => {} + let slot = match key.trim() { + "version" => &mut version, + "release-url" => &mut release_url, + k if k == asset_name => &mut hash, + _ => continue, + }; + if slot.replace(value.trim()).is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("duplicate `{key}`"), + )); } } - let version = version.ok_or("missing `version=` line")?; - let hash = hash.ok_or_else(|| format!("missing hash for package `{package_name}`"))?; - Ok((version, hash)) + let version = version + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing `version=` line"))?; + let hash = hash.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("missing hash for asset `{asset_name}`"), + ) + })?; + if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid SHA-256 for `{asset_name}`"), + )); + } + Ok(( + Release::new(version.to_string(), release_url)?, + hash.to_ascii_lowercase(), + )) } fn read_version_from_package_json(path: &Path) -> String { @@ -490,22 +602,35 @@ fn read_version_from_package_json(path: &Path) -> String { .to_string() } -fn fetch_release_hash(version: &str, asset_name: &str) -> String { - let url = format!( - "https://github.com/github/copilot-cli/releases/download/v{version}/SHA256SUMS.txt" - ); +fn fetch_release_hash(release: &Release, asset_name: &str) -> String { + let url = release.asset_url("SHA256SUMS.txt"); let checksums = download_with_retry(&url); let checksums = std::str::from_utf8(&checksums).expect("SHA256SUMS.txt is not valid UTF-8"); find_sha256_for_asset(checksums, asset_name) } fn find_sha256_for_asset(sums: &str, asset_name: &str) -> String { - sums.lines() - .find_map(|line| { - let (hash, name) = line.split_once(char::is_whitespace)?; - (name.trim_start().trim_start_matches('*') == asset_name).then(|| hash.to_string()) - }) - .unwrap_or_else(|| panic!("SHA256SUMS.txt does not contain {asset_name}")) + let mut matches = sums.lines().filter_map(|line| { + let (hash, name) = line.split_once(char::is_whitespace)?; + (name + .trim_start() + .strip_prefix('*') + .unwrap_or(name.trim_start()) + == asset_name) + .then_some(hash) + }); + let hash = matches + .next() + .unwrap_or_else(|| panic!("SHA256SUMS.txt does not contain {asset_name}")); + assert!( + matches.next().is_none(), + "SHA256SUMS.txt contains duplicate {asset_name}" + ); + assert!( + hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()), + "SHA256SUMS.txt contains invalid SHA-256 for {asset_name}" + ); + hash.to_ascii_lowercase() } #[derive(Clone, Copy)] @@ -794,26 +919,10 @@ fn cached_download( expected_hash: &str, cache_dir: &Option, ) -> Vec { - if let Some(dir) = cache_dir { - let cached_path = dir.join(cache_key); - if cached_path.is_file() { - match std::fs::read(&cached_path) { - Ok(data) if verify_hash(&data, expected_hash) => { - // Silent cache hit — nothing to surface. - return data; - } - Ok(_) => { - println!("cargo:warning=Cached archive hash mismatch, re-downloading"); - let _ = std::fs::remove_file(&cached_path); - } - Err(e) => { - println!( - "cargo:warning=Failed to read cache {}, re-downloading: {e}", - cached_path.display() - ); - } - } - } + if let Some(dir) = cache_dir + && let Some(data) = read_verified_cache(&dir.join(cache_key), expected_hash) + { + return data; } println!("cargo:warning=Downloading {url}"); @@ -846,6 +955,25 @@ fn cached_download( data } +fn read_verified_cache(path: &Path, expected_hash: &str) -> Option> { + if path.is_file() { + match std::fs::read(path) { + Ok(data) if verify_hash(&data, expected_hash) => return Some(data), + Ok(_) => { + println!("cargo:warning=Cached archive hash mismatch, re-downloading"); + let _ = std::fs::remove_file(path); + } + Err(e) => { + println!( + "cargo:warning=Failed to read cache {}, re-downloading: {e}", + path.display() + ); + } + } + } + None +} + /// Maximum retries after the initial HTTP attempt for transient errors. const MAX_RETRIES: u32 = 5; @@ -981,3 +1109,7 @@ fn verify_hash(data: &[u8], expected: &str) -> bool { hasher.update(data); format!("{:x}", hasher.finalize()) == expected } + +#[cfg(test)] +#[path = "in_process/tests.rs"] +mod tests; diff --git a/rust/build/in_process/tests.rs b/rust/build/in_process/tests.rs new file mode 100644 index 0000000000..89a5c5f5e5 --- /dev/null +++ b/rust/build/in_process/tests.rs @@ -0,0 +1,575 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +#![cfg(test)] + +use std::fs; +use std::io::{Cursor, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; +use tempfile::TempDir; + +use super::*; + +const VERSION: &str = "1.2.3-unstable.20260923"; +const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const PLATFORMS: &[&str] = &[ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "linuxmusl-arm64", + "linuxmusl-x64", + "win32-arm64", + "win32-x64", +]; + +fn release(sdk: bool) -> Release { + Release::new( + VERSION.into(), + sdk.then_some(format!( + "https://github.com/github/copilot-sdk/releases/download/runtime-{VERSION}" + )) + .as_deref(), + ) + .unwrap() +} + +#[test] +fn legacy_and_unstable_snapshots_resolve_every_asset_and_checksums_consistently() { + for sdk in [false, true] { + let release = release(sdk); + let base = if sdk { + format!("https://github.com/github/copilot-sdk/releases/download/runtime-{VERSION}") + } else { + format!("https://github.com/github/copilot-cli/releases/download/v{VERSION}") + }; + let metadata = if sdk { + format!("release-url={base}\r\n") + } else { + String::new() + }; + assert_eq!( + release.asset_url("SHA256SUMS.txt"), + format!("{base}/SHA256SUMS.txt") + ); + for target in PLATFORMS { + let extension = if target.starts_with("win32") { + "zip" + } else { + "tar.gz" + }; + let cli_asset = format!("copilot-{target}.{extension}"); + let runtime_asset = format!("github-copilot-{VERSION}-{target}.tgz"); + for (key, asset) in [ + (cli_asset.clone(), cli_asset), + (format!("copilot-{target}"), runtime_asset), + ] { + let snapshot = format!( + "# fixture\r\n\r\nversion={VERSION}\r\n{metadata}{key}={}\r\n", + HASH.to_ascii_uppercase() + ); + let (parsed, hash) = parse_snapshot(&snapshot, &key).unwrap(); + assert_eq!(parsed, release); + assert_eq!(hash, HASH); + assert_eq!(parsed.asset_url(&asset), format!("{base}/{asset}")); + let expected_key = if sdk { + format!("copilot-sdk-runtime-{VERSION}-{asset}") + } else { + format!("v{VERSION}-{asset}") + }; + assert_eq!(parsed.cache_key(&asset), expected_key); + assert_eq!( + find_sha256_for_asset(&format!("{HASH} *{asset}\r\n"), &asset), + HASH + ); + } + } + } +} + +#[test] +fn explicit_legacy_url_and_omitted_url_have_the_same_identity() { + for version in [ + "1.2.3", + "1.2.3-4", + "0.0.0-dev", + "1.2.3-unstable.r123.gabcdef0", + "1.2.3-4.unstable.r123.gabcdef0", + ] { + let implicit = Release::new(version.into(), None).unwrap(); + let explicit = Release::new( + version.into(), + Some(&format!( + "https://github.com/github/copilot-cli/releases/download/v{version}" + )), + ) + .unwrap(); + assert_eq!(implicit, explicit); + assert_eq!(implicit.cache_identity(), version); + } + assert_ne!( + release(false).cache_identity(), + release(true).cache_identity() + ); +} + +#[test] +fn source_checkouts_without_snapshots_resolve_canonical_unstable_releases() { + let root = fixture_dir(); + let manifest_dir = root.path().join("rust"); + let node_dir = root.path().join("nodejs"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::create_dir_all(&node_dir).unwrap(); + assert!(!manifest_dir.join("cli-version.txt").exists()); + assert!(!manifest_dir.join("cli-version-in-process.txt").exists()); + + for (version, sdk_release) in [ + ("1.2.3", false), + ("1.2.3-4", false), + ("0.0.0-dev", false), + ("1.2.3-unstable.r123.gabcdef0", true), + ("1.2.3-4.unstable.r123.gabcdef0", true), + ("0.0.0-0.unstable.r1.g0000000", true), + ("1.2.3-unstable.20260923", false), + ("1.2.3.unstable.r123.gabcdef0", false), + ("1.2.3-unstable.r0.gabcdef0", false), + ("1.2.3-unstable.r0123.gabcdef0", false), + ("01.2.3-unstable.r123.gabcdef0", false), + ("1.2.3-04.unstable.r123.gabcdef0", false), + ("1.2.3-unstable.r123.gabcdef00", false), + ("1.2.3-unstable.r123.gABCDEF0", false), + ] { + fs::write( + node_dir.join("package.json"), + serde_json::json!({ "copilotCliVersion": version }).to_string(), + ) + .unwrap(); + let (release, hash) = resolve_version_and_optional_hash(&manifest_dir, "copilot-linux-x64"); + assert_eq!(release.version, version); + assert_eq!(hash, None); + let base = if sdk_release { + format!("https://github.com/github/copilot-sdk/releases/download/runtime-{version}") + } else { + format!("https://github.com/github/copilot-cli/releases/download/v{version}") + }; + for asset in [ + "SHA256SUMS.txt".into(), + "copilot-linux-x64.tar.gz".into(), + format!("github-copilot-{version}-linux-x64.tgz"), + ] { + assert_eq!(release.asset_url(&asset), format!("{base}/{asset}")); + let key = if sdk_release { + format!("copilot-sdk-runtime-{version}-{asset}") + } else { + format!("v{version}-{asset}") + }; + assert_eq!(release.cache_key(&asset), key); + } + } +} + +#[test] +fn source_checkouts_still_prefer_snapshots_and_preserve_legacy_locations() { + let root = fixture_dir(); + let manifest_dir = root.path().join("rust"); + let node_dir = root.path().join("nodejs"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::create_dir_all(&node_dir).unwrap(); + fs::write( + node_dir.join("package.json"), + r#"{"copilotCliVersion":"9.9.9"}"#, + ) + .unwrap(); + let version = "1.2.3-4.unstable.r123.gabcdef0"; + for sdk_release in [false, true] { + let base = if sdk_release { + format!("https://github.com/github/copilot-sdk/releases/download/runtime-{version}") + } else { + format!("https://github.com/github/copilot-cli/releases/download/v{version}") + }; + let location = if sdk_release { + format!("release-url={base}\n") + } else { + String::new() + }; + fs::write( + manifest_dir.join("cli-version-in-process.txt"), + format!("version={version}\n{location}copilot-linux-x64={HASH}\n"), + ) + .unwrap(); + let (release, hash) = resolve_version_and_optional_hash(&manifest_dir, "copilot-linux-x64"); + assert_eq!(release.version, version); + assert_eq!(hash.as_deref(), Some(HASH)); + assert_eq!( + release.asset_url("SHA256SUMS.txt"), + format!("{base}/SHA256SUMS.txt") + ); + } +} + +#[test] +fn snapshots_reject_mismatched_versions_untrusted_locations_and_invalid_hashes() { + for url in [ + "https://github.com/github/copilot-sdk/releases/download/runtime-9.9.9", + "https://github.com/github/copilot-sdk/releases/latest", + "https://github.com/github/copilot-agent-runtime/releases/download/runtime-1.2.3", + "https://token@github.com/github/copilot-sdk/releases/download/runtime-1.2.3", + "https://github.com/github/copilot-cli/releases/download/v1.2.3/", + "https://github.com/github/copilot-cli/releases/download/v1.2.3?token=secret", + "http://github.com/github/copilot-cli/releases/download/v1.2.3", + ] { + let snapshot = format!("version=1.2.3\nrelease-url={url}\nasset={HASH}"); + assert!(parse_snapshot(&snapshot, "asset").is_err(), "{url}"); + } + for snapshot in [ + format!("version=\nasset={HASH}"), + format!("version=../1.2.3\nasset={HASH}"), + format!("version=1.2.3\nversion=1.2.4\nasset={HASH}"), + format!("version=1.2.3\nasset={HASH}\nasset={HASH}"), + format!("version=1.2.3\nrelease-url=\nrelease-url=\nasset={HASH}"), + format!("version=1.2.3\nother={HASH}"), + "version=1.2.3\nasset=bad-hash".into(), + format!("version=1.2.3\nmalformed line\nasset={HASH}"), + ] { + assert!(parse_snapshot(&snapshot, "asset").is_err(), "{snapshot}"); + } +} + +#[test] +fn checksum_lookup_rejects_missing_duplicate_and_invalid_entries() { + for sums in [ + format!("{HASH} asset-other\n"), + format!("{HASH} asset\n{HASH} *asset\n"), + "invalid asset\n".into(), + ] { + assert!(std::panic::catch_unwind(|| find_sha256_for_asset(&sums, "asset")).is_err()); + } +} + +fn fixture_dir() -> TempDir { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("acquisition-tests"); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix("fixture-") + .tempdir_in(root) + .unwrap() +} + +#[test] +fn cache_hits_are_verified_and_corruption_is_evicted() { + let fixture = fixture_dir(); + let bytes = b"verified release archive"; + let hash = format!("{:x}", Sha256::digest(bytes)); + let path = fixture.path().join(release(true).cache_key("asset")); + assert_eq!(read_verified_cache(&path, &hash), None); + fs::write(&path, bytes).unwrap(); + assert_eq!( + read_verified_cache(&path, &hash).as_deref(), + Some(bytes.as_slice()) + ); + fs::write(&path, b"corrupt release archive").unwrap(); + assert_eq!(read_verified_cache(&path, &hash), None); + assert!(!path.exists()); +} + +fn tar_archive(files: &[(&str, &[u8])]) -> Vec { + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + for (name, bytes) in files { + append_archive_file(&mut archive, name, bytes, 0o755); + } + archive.into_inner().unwrap().finish().unwrap() +} + +struct Fixture { + dir: TempDir, + platform: Platform, + release: Release, +} + +impl Fixture { + fn new(sdk: bool, windows: bool) -> Self { + let fixture = Self { + dir: fixture_dir(), + platform: Platform { + package_name: if windows { + "copilot-win32-x64" + } else { + "copilot-linux-x64" + }, + binary_name: if windows { "copilot.exe" } else { "copilot" }, + }, + release: release(sdk), + }; + for name in ["out", "cache", "extracted"] { + fs::create_dir_all(fixture.path(name)).unwrap(); + } + let cli = if windows { + let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new())); + archive + .start_file("copilot.exe", zip::write::SimpleFileOptions::default()) + .unwrap(); + archive.write_all(b"MZ fixture CLI").unwrap(); + archive.finish().unwrap().into_inner() + } else { + tar_archive(&[("copilot", b"fixture CLI")]) + }; + let target = fixture + .platform + .package_name + .strip_prefix("copilot-") + .unwrap(); + let runtime = tar_archive(&[ + ( + &format!("package/{}", fixture.platform.runtime_wrapper_name()), + b"fixture wrapper", + ), + ( + &format!("package/prebuilds/{target}/runtime.node"), + b"fixture native library", + ), + ("package/tls/roots.pem", b"fixture support file"), + ]); + for (snapshot, key, asset, bytes) in [ + ( + "cli-version.txt", + fixture.platform.cli_asset_name(), + fixture.platform.cli_asset_name(), + cli, + ), + ( + "cli-version-in-process.txt", + fixture.platform.package_name.into(), + format!("github-copilot-{VERSION}-{target}.tgz"), + runtime, + ), + ] { + let location = if sdk { + format!( + "release-url=https://github.com/github/copilot-sdk/releases/download/runtime-{VERSION}\n" + ) + } else { + String::new() + }; + fs::write( + fixture.path(snapshot), + format!( + "version={VERSION}\n{location}{key}={:x}\n", + Sha256::digest(&bytes) + ), + ) + .unwrap(); + fs::write( + fixture + .path("cache") + .join(fixture.release.cache_key(&asset)), + bytes, + ) + .unwrap(); + } + fixture + } + + fn path(&self, path: &str) -> PathBuf { + self.dir.path().join(path) + } + + fn command(&self, bundled: bool, in_process: bool) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "implementation::tests::run_build_script", + "--nocapture", + ]) + .env("COPILOT_ACQUISITION_TEST_CHILD", "1") + .env("CARGO_MANIFEST_DIR", self.dir.path()) + .env("OUT_DIR", self.path("out")) + .env("BUNDLED_CLI_CACHE_DIR", self.path("cache")) + .env("COPILOT_CLI_EXTRACT_DIR", self.path("extracted")) + .env( + "CARGO_CFG_TARGET_OS", + if self.platform.package_name.contains("win32") { + "windows" + } else { + "linux" + }, + ) + .env("CARGO_CFG_TARGET_ARCH", "x86_64") + .env("CARGO_CFG_TARGET_ENV", "") + .env("CARGO_FEATURE_RUNTIME", "1") + .env_remove("COPILOT_SKIP_CLI_DOWNLOAD") + .env_remove("DOCS_RS") + .env_remove("CARGO_FEATURE_LOCAL_RUNTIME") + .env_remove("CARGO_FEATURE_BUNDLED_CLI") + .env_remove("CARGO_FEATURE_IN_PROCESS"); + if bundled { + command.env("CARGO_FEATURE_BUNDLED_CLI", "1"); + } + if in_process { + command.env("CARGO_FEATURE_IN_PROCESS", "1"); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + command + } +} + +#[track_caller] +fn succeeded(output: Output) -> String { + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(!stdout.contains("Downloading "), "{stdout}"); + stdout +} + +#[test] +fn run_build_script() { + if std::env::var_os("COPILOT_ACQUISITION_TEST_CHILD").is_some() { + super::main(); + } +} + +#[test] +fn seeded_builds_support_bundled_in_process_and_extracted_modes_for_both_locations() { + for sdk in [false, true] { + for windows in [false, true] { + for bundled in [false, true] { + for in_process in [false, true] { + let fixture = Fixture::new(sdk, windows); + let stdout = succeeded(fixture.command(bundled, in_process).output().unwrap()); + assert!(stdout.contains(&format!( + "cargo:rustc-env=COPILOT_SDK_CLI_CACHE_ID={}", + fixture.release.cache_identity() + ))); + if bundled { + assert!(stdout.contains("cargo:rustc-cfg=has_bundled_cli")); + assert!(fixture.path("out/copilot_cli.archive").is_file()); + let runtime = + fs::read(fixture.path("out/copilot_runtime.archive")).unwrap(); + assert!(archive_contains_tar_entry( + &runtime, + fixture.platform.runtime_wrapper_name() + )); + assert!(archive_contains_tar_entry(&runtime, "roots.pem")); + assert_eq!( + archive_contains_tar_entry( + &runtime, + fixture.platform.runtime_library_name() + ), + in_process + ); + } else { + assert!(stdout.contains("cargo:rustc-cfg=has_extracted_cli")); + assert!( + fixture + .path("extracted") + .join(fixture.platform.runtime_wrapper_name()) + .is_file() + ); + assert!(fixture.path("extracted/tls/roots.pem").is_file()); + assert_eq!( + fixture + .path("extracted") + .join(fixture.platform.runtime_library_name()) + .is_file(), + in_process + ); + // A valid extracted cache needs neither archive nor a network lookup. + fs::remove_dir_all(fixture.path("cache")).unwrap(); + succeeded(fixture.command(bundled, in_process).output().unwrap()); + } + } + } + } + } +} + +#[test] +fn stale_extracted_markers_reinstall_verified_runtime_assets() { + let fixture = Fixture::new(true, false); + succeeded(fixture.command(false, true).output().unwrap()); + let wrapper = fixture + .path("extracted") + .join(fixture.platform.runtime_wrapper_name()); + fs::write(&wrapper, b"stale wrapper").unwrap(); + fs::write( + fixture.path("extracted/.hostless-runtime-assets-v1"), + format!("{VERSION}\n{HASH}\n"), + ) + .unwrap(); + fs::write(fixture.path("extracted/stale-file"), b"old payload").unwrap(); + succeeded(fixture.command(false, true).output().unwrap()); + assert_eq!(fs::read(wrapper).unwrap(), b"fixture wrapper"); + assert!(!fixture.path("extracted/stale-file").exists()); +} + +#[test] +fn builds_reject_inconsistent_snapshot_versions_and_locations_before_acquisition() { + for change_version in [false, true] { + let fixture = Fixture::new(true, false); + let path = fixture.path("cli-version.txt"); + let contents = fs::read_to_string(&path).unwrap(); + let changed = if change_version { + contents.replace(VERSION, "9.9.9") + } else { + contents.replace( + &format!("release-url=https://github.com/github/copilot-sdk/releases/download/runtime-{VERSION}\n"), + "" + ) + }; + fs::write(path, changed).unwrap(); + let output = fixture.command(true, false).output().unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("same version and release URL")); + assert!(!String::from_utf8_lossy(&output.stdout).contains("Downloading ")); + } +} + +#[test] +fn external_stream_only_builds_need_no_runtime_artifacts() { + let fixture = Fixture::new(true, false); + fs::remove_file(fixture.path("cli-version.txt")).unwrap(); + fs::remove_file(fixture.path("cli-version-in-process.txt")).unwrap(); + fs::remove_dir_all(fixture.path("cache")).unwrap(); + let mut command = fixture.command(false, false); + command.env_remove("CARGO_FEATURE_RUNTIME"); + let stdout = succeeded(command.output().unwrap()); + assert!(!stdout.contains("cargo:rustc-env=COPILOT_SDK_CLI_VERSION")); + assert!(!stdout.contains("cargo:rustc-cfg=has_")); + assert!(!fixture.path("extracted/copilot-runtime").exists()); +} + +#[test] +fn skip_modes_need_no_snapshots_and_local_runtime_preserves_bundled_precedence() { + for skip in [ + "DOCS_RS", + "COPILOT_SKIP_CLI_DOWNLOAD", + "CARGO_FEATURE_LOCAL_RUNTIME", + ] { + let fixture = Fixture::new(true, false); + fs::remove_file(fixture.path("cli-version.txt")).unwrap(); + fs::remove_file(fixture.path("cli-version-in-process.txt")).unwrap(); + fs::remove_dir_all(fixture.path("cache")).unwrap(); + let mut command = fixture.command(false, true); + command.env(skip, "1"); + let stdout = succeeded(command.output().unwrap()); + assert!(!stdout.contains("cargo:rustc-cfg=has_")); + } + let fixture = Fixture::new(true, false); + let mut command = fixture.command(true, true); + command.env("CARGO_FEATURE_LOCAL_RUNTIME", "1"); + assert!(succeeded(command.output().unwrap()).contains("cargo:rustc-cfg=has_bundled_cli")); +} diff --git a/rust/scripts/snapshot-bundled-cli-version.sh b/rust/scripts/snapshot-bundled-cli-version.sh index 0045f5e6c8..6df8cfbdbd 100755 --- a/rust/scripts/snapshot-bundled-cli-version.sh +++ b/rust/scripts/snapshot-bundled-cli-version.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. All rights reserved. # # Snapshot the Copilot CLI version + per-platform SHA-256 hashes for the # rust crate's bundled-CLI build.rs. Runs at SDK publish time, mirroring @@ -17,21 +18,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" RUST_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" REPO_ROOT="$(cd "${RUST_DIR}/.." && pwd)" -PACKAGE_FILE="${REPO_ROOT}/nodejs/package.json" OUTPUT="${RUST_DIR}/cli-version.txt" - -if [[ ! -f "${PACKAGE_FILE}" ]]; then - echo "error: ${PACKAGE_FILE} not found" >&2 - exit 1 -fi - -VERSION="$(node -e "console.log(require('${PACKAGE_FILE}').copilotCliVersion)")" -if [[ -z "${VERSION}" ]]; then - echo "error: could not read copilotCliVersion from ${PACKAGE_FILE}" >&2 - exit 1 -fi -CHECKSUMS_URL="https://github.com/github/copilot-cli/releases/download/v${VERSION}/SHA256SUMS.txt" -SHA256SUMS="$(curl --fail --silent --show-error --location --retry 3 "${CHECKSUMS_URL}")" +source "${SCRIPT_DIR}/snapshot-release.sh" "$@" ASSETS=( "copilot-darwin-arm64.tar.gz" @@ -50,12 +38,11 @@ trap 'rm -f "${TEMP_OUTPUT}"' EXIT echo "# Auto-generated by rust/scripts/snapshot-bundled-cli-version.sh" echo "# Do not edit. Regenerated by the publish workflow on every release." echo "version=${VERSION}" + if [[ "${RELEASE_URL}" != "${DEFAULT_RELEASE_URL}" ]]; then + echo "release-url=${RELEASE_URL}" + fi for asset in "${ASSETS[@]}"; do - hash="$(printf '%s\n' "${SHA256SUMS}" | awk -v asset="${asset}" '$2 == asset || $2 == "*" asset { print $1; exit }')" - if [[ -z "${hash}" ]]; then - echo "error: SHA256SUMS.txt does not contain ${asset}" >&2 - exit 1 - fi + hash="$(snapshot_hash "${asset}")" echo "${asset}=${hash}" done } > "${TEMP_OUTPUT}" diff --git a/rust/scripts/snapshot-bundled-in-process-version.sh b/rust/scripts/snapshot-bundled-in-process-version.sh index 9fe2298c78..2dd270a1d6 100755 --- a/rust/scripts/snapshot-bundled-in-process-version.sh +++ b/rust/scripts/snapshot-bundled-in-process-version.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. All rights reserved. # # Snapshot the Copilot CLI version + per-platform release hashes for the # rust crate's bundled-in-process build path. @@ -8,21 +9,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" RUST_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" REPO_ROOT="$(cd "${RUST_DIR}/.." && pwd)" -PACKAGE_FILE="${REPO_ROOT}/nodejs/package.json" OUTPUT="${RUST_DIR}/cli-version-in-process.txt" - -if [[ ! -f "${PACKAGE_FILE}" ]]; then - echo "error: ${PACKAGE_FILE} not found" >&2 - exit 1 -fi - -VERSION="$(node -e "console.log(require('${PACKAGE_FILE}').copilotCliVersion)")" -if [[ -z "${VERSION}" ]]; then - echo "error: could not read copilotCliVersion from ${PACKAGE_FILE}" >&2 - exit 1 -fi -CHECKSUMS_URL="https://github.com/github/copilot-cli/releases/download/v${VERSION}/SHA256SUMS.txt" -SHA256SUMS="$(curl --fail --silent --show-error --location --retry 3 "${CHECKSUMS_URL}")" +source "${SCRIPT_DIR}/snapshot-release.sh" "$@" PACKAGES=( "copilot-darwin-arm64" @@ -41,14 +29,13 @@ trap 'rm -f "${TEMP_OUTPUT}"' EXIT echo "# Auto-generated by rust/scripts/snapshot-bundled-in-process-version.sh" echo "# Do not edit. Regenerated by the publish workflow on every release." echo "version=${VERSION}" + if [[ "${RELEASE_URL}" != "${DEFAULT_RELEASE_URL}" ]]; then + echo "release-url=${RELEASE_URL}" + fi for package in "${PACKAGES[@]}"; do platform="${package#copilot-}" asset="github-copilot-${VERSION}-${platform}.tgz" - hash="$(printf '%s\n' "${SHA256SUMS}" | awk -v asset="${asset}" '$2 == asset || $2 == "*" asset { print $1; exit }')" - if [[ -z "${hash}" ]]; then - echo "error: SHA256SUMS.txt does not contain ${asset}" >&2 - exit 1 - fi + hash="$(snapshot_hash "${asset}")" echo "${package}=${hash}" done } > "${TEMP_OUTPUT}" diff --git a/rust/scripts/snapshot-release.sh b/rust/scripts/snapshot-release.sh new file mode 100644 index 0000000000..37bda6edf9 --- /dev/null +++ b/rust/scripts/snapshot-release.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Sourced by the two snapshot entry points. + +usage() { + cat < + https://github.com/github/copilot-sdk/releases/download/runtime- + Default: the copilot-cli URL. + --checksums FILE Read local SHA256SUMS.txt instead of downloading it. + --output FILE Write to a selected-source staging file instead of this script's crate. + --help Show this help. + +No environment variables or credentials are required. Local checksums let +packaging run before the public release exists; the snapshot still pins its +final public URL. Both snapshot scripts must use the same version and URL. +EOF +} + +VERSION="" +RELEASE_URL="" +CHECKSUMS_FILE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) usage; exit 0 ;; + --version|--release-url|--checksums|--output) + if [[ $# -lt 2 || -z "$2" || "$2" == --* ]]; then + echo "error: $1 requires a value" >&2 + exit 1 + fi + case "$1" in + --version) VERSION="$2" ;; + --release-url) RELEASE_URL="$2" ;; + --checksums) CHECKSUMS_FILE="$2" ;; + --output) OUTPUT="$2" ;; + esac + shift 2 + ;; + *) echo "error: unknown argument: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ -z "${VERSION}" ]]; then + PACKAGE_FILE="${REPO_ROOT}/nodejs/package.json" + if [[ ! -f "${PACKAGE_FILE}" ]]; then + echo "error: ${PACKAGE_FILE} not found" >&2 + exit 1 + fi + VERSION="$(node -e 'console.log(require(process.argv[1]).copilotCliVersion ?? "")' "${PACKAGE_FILE}")" +fi +if [[ ! "${VERSION}" =~ ^[0-9][a-zA-Z0-9.+-]*$ ]]; then + echo "error: invalid runtime version: ${VERSION}" >&2 + exit 1 +fi + +DEFAULT_RELEASE_URL="https://github.com/github/copilot-cli/releases/download/v${VERSION}" +RELEASE_URL="${RELEASE_URL:-${DEFAULT_RELEASE_URL}}" +if [[ "${RELEASE_URL}" != "${DEFAULT_RELEASE_URL}" && + "${RELEASE_URL}" != "https://github.com/github/copilot-sdk/releases/download/runtime-${VERSION}" ]]; then + echo "error: --release-url must be the exact public copilot-cli/v or copilot-sdk/runtime- release URL" >&2 + exit 1 +fi + +if [[ -n "${CHECKSUMS_FILE}" ]]; then + SHA256SUMS="$(cat "${CHECKSUMS_FILE}")" +else + SHA256SUMS="$(curl --fail --silent --show-error --location --retry 3 "${RELEASE_URL}/SHA256SUMS.txt")" +fi +# Accept checksum files produced on Windows as well as Unix. +SHA256SUMS="${SHA256SUMS//$'\r'/}" + +snapshot_hash() { + local asset="$1" hash + hash="$(printf '%s\n' "${SHA256SUMS}" | awk -v asset="${asset}" '$2 == asset || $2 == "*" asset { print $1 }')" + if [[ ! "${hash}" =~ ^[a-fA-F0-9]{64}$ ]]; then + echo "error: SHA256SUMS.txt must contain one valid SHA-256 for ${asset}" >&2 + return 1 + fi + printf '%s\n' "${hash}" | tr '[:upper:]' '[:lower:]' +} diff --git a/rust/scripts/snapshot-version.test.mjs b/rust/scripts/snapshot-version.test.mjs new file mode 100644 index 0000000000..29f69cbb8a --- /dev/null +++ b/rust/scripts/snapshot-version.test.mjs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const targets = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "linuxmusl-arm64", + "linuxmusl-x64", + "win32-arm64", + "win32-x64", +]; +const scripts = [ + ["snapshot-bundled-cli-version.sh", "cli-version.txt", false], + ["snapshot-bundled-in-process-version.sh", "cli-version-in-process.txt", true], +]; + +function fixture(t, version) { + const root = mkdtempSync(join(scriptsDir, ".snapshot-test-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const rust = join(root, "rust"); + mkdirSync(join(rust, "scripts"), { recursive: true }); + mkdirSync(join(root, "nodejs")); + mkdirSync(join(root, "bin")); + writeFileSync(join(root, "nodejs", "package.json"), JSON.stringify({ copilotCliVersion: version })); + for (const script of [...scripts.map(([script]) => script), "snapshot-release.sh"]) { + copyFileSync(join(scriptsDir, script), join(rust, "scripts", script)); + } + const hashes = new Map(); + for (const target of targets) { + for (const asset of [ + `copilot-${target}.${target.startsWith("win32") ? "zip" : "tar.gz"}`, + `github-copilot-${version}-${target}.tgz`, + ]) { + hashes.set(asset, createHash("sha256").update(asset).digest("hex")); + } + } + const sums = [...hashes].map(([asset, hash]) => `${hash.toUpperCase()} *${asset}`).join("\r\n"); + writeFileSync(join(rust, "SHA256SUMS.txt"), `${sums}\r\n`); + // Capture the download boundary without contacting GitHub. + writeFileSync(join(root, "bin", "curl"), `#!/usr/bin/env bash +printf '%s\\n' "$@" > curl-args.txt +cat SHA256SUMS.txt +`, { mode: 0o755 }); + return { + rust, + hashes, + run(script, args = []) { + const result = spawnSync("bash", [ + "-c", + 'export PATH="$PWD/../bin:$PATH"; exec bash "$@"', + "snapshot-test", + join("scripts", script), + ...args, + ], { + cwd: rust, + encoding: "utf8", + windowsHide: true, + timeout: 30_000, + }); + assert.ifError(result.error); + return result; + }, + }; +} + +function assertSnapshot(fixture, filename, version, runtime, releaseUrl) { + const contents = readFileSync(join(fixture.rust, filename), "utf8"); + const entries = new Map(contents.split(/\r?\n/).filter((line) => line && !line.startsWith("#")) + .map((line) => line.split("="))); + assert.equal(entries.get("version"), version); + assert.equal(entries.get("release-url"), releaseUrl); + assert.equal(entries.size, targets.length + 1 + Number(Boolean(releaseUrl))); + for (const target of targets) { + const key = runtime ? `copilot-${target}` : `copilot-${target}.${target.startsWith("win32") ? "zip" : "tar.gz"}`; + const asset = runtime ? `github-copilot-${version}-${target}.tgz` : key; + assert.equal(entries.get(key), fixture.hashes.get(asset)); + } +} + +test("no-option snapshots keep the legacy exact version, URL, and hash format", (t) => { + const version = "1.2.3-4"; + const f = fixture(t, version); + for (const [script, output, runtime] of scripts) { + const result = f.run(script); + assert.equal(result.status, 0, result.stderr); + assertSnapshot(f, output, version, runtime, undefined); + assert.match( + readFileSync(join(f.rust, "curl-args.txt"), "utf8"), + /https:\/\/github\.com\/github\/copilot-cli\/releases\/download\/v1\.2\.3-4\/SHA256SUMS\.txt/, + ); + } +}); + +test("local checksum snapshots pin the final unstable URL without publication or sibling metadata", (t) => { + const version = "1.2.3-unstable.20260923"; + const url = `https://github.com/github/copilot-sdk/releases/download/runtime-${version}`; + const f = fixture(t, version); + rmSync(join(f.rust, "..", "nodejs"), { recursive: true }); + for (const [script, output, runtime] of scripts) { + const result = f.run(script, ["--version", version, "--release-url", url, "--checksums", "SHA256SUMS.txt"]); + assert.equal(result.status, 0, result.stderr); + assertSnapshot(f, output, version, runtime, url); + } + assert.throws(() => readFileSync(join(f.rust, "curl-args.txt")), { code: "ENOENT" }); +}); + +test("URL-only override fetches checksums from the exact selected release", (t) => { + const version = "1.2.3-unstable.20260923"; + const url = `https://github.com/github/copilot-sdk/releases/download/runtime-${version}`; + const f = fixture(t, version); + for (const [script, output, runtime] of scripts) { + const result = f.run(script, ["--release-url", url]); + assert.equal(result.status, 0, result.stderr); + assertSnapshot(f, output, version, runtime, url); + assert.equal(readFileSync(join(f.rust, "curl-args.txt"), "utf8").trim().split("\n").at(-1), `${url}/SHA256SUMS.txt`); + } +}); + +test("reviewed producers can stamp normal promotions without changing legacy product code", (t) => { + for (const version of ["1.2.3", "1.2.3-4"]) { + const f = fixture(t, version); + const product = join(f.rust, "..", "old-product", "rust"); + mkdirSync(product, { recursive: true }); + writeFileSync(join(product, "build.rs"), "// unchanged legacy build script\n"); + rmSync(join(f.rust, "..", "nodejs"), { recursive: true }); + for (const [script, output, runtime] of scripts) { + writeFileSync(join(f.rust, output), "reviewed snapshot must remain unchanged"); + const result = f.run(script, [ + "--version", version, + "--release-url", `https://github.com/github/copilot-cli/releases/download/v${version}`, + "--checksums", "SHA256SUMS.txt", + "--output", join(product, output), + ]); + assert.equal(result.status, 0, result.stderr); + assertSnapshot({ ...f, rust: product }, output, version, runtime, undefined); + assert.equal(readFileSync(join(f.rust, output), "utf8"), "reviewed snapshot must remain unchanged"); + } + assert.equal(readFileSync(join(product, "build.rs"), "utf8"), "// unchanged legacy build script\n"); + assert.throws(() => readFileSync(join(product, "scripts", "snapshot-release.sh")), { code: "ENOENT" }); + assert.throws(() => readFileSync(join(f.rust, "curl-args.txt")), { code: "ENOENT" }); + } +}); + +test("invalid or mismatched release locations fail before acquisition", (t) => { + const f = fixture(t, "1.2.3"); + for (const [script] of scripts) { + for (const url of [ + "https://github.com/github/copilot-sdk/releases/download/runtime-9.9.9", + "https://github.com/github/copilot-cli/releases/latest", + "https://user:token@github.com/github/copilot-sdk/releases/download/runtime-1.2.3", + ]) { + const result = f.run(script, ["--release-url", url]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exact public/); + } + } + assert.throws(() => readFileSync(join(f.rust, "curl-args.txt")), { code: "ENOENT" }); +}); + +test("missing, duplicate, and invalid hashes preserve an existing snapshot", (t) => { + const f = fixture(t, "1.2.3"); + for (const [script, output, runtime] of scripts) { + const asset = runtime ? "github-copilot-1.2.3-darwin-arm64.tgz" : "copilot-darwin-arm64.tar.gz"; + const hash = f.hashes.get(asset); + for (const contents of ["", `bad ${asset}\n`, `${hash} ${asset}\n${hash} ${asset}\n`]) { + writeFileSync(join(f.rust, output), "original snapshot"); + writeFileSync(join(f.rust, "invalid-sums.txt"), contents); + const result = f.run(script, ["--checksums", "invalid-sums.txt"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /one valid SHA-256/); + assert.equal(readFileSync(join(f.rust, output), "utf8"), "original snapshot"); + } + } +}); diff --git a/rust/src/ahp_host.rs b/rust/src/ahp_host.rs index c3e9fc27cf..2b42b86ff0 100644 --- a/rust/src/ahp_host.rs +++ b/rust/src/ahp_host.rs @@ -958,6 +958,7 @@ fn resume_config_from_host( "sessionId" => {}, "continuePendingWork" => config.continue_pending_work = serde_json::from_value(value.clone())?, "suppressResumeEvent" => config.suppress_resume_event = serde_json::from_value(value.clone())?, + "allowTranscriptRecovery" => config.allow_transcript_recovery = serde_json::from_value(value.clone())?, $($name => config.$field = serde_json::from_value(value.clone())?,)* _ => return Err(handoff_error("Unsupported AHP resume configuration setting")), } @@ -988,6 +989,9 @@ pub(crate) fn resume_config_for_host(config: &ResumeSessionConfig) -> Result Option { .get_or_init(|| { #[cfg(has_bundled_cli)] { - let dir = default_install_dir(CLI_VERSION); + let dir = default_install_dir(CLI_CACHE_ID); match install_cli( &dir, build_time::CLI_ARCHIVE, @@ -171,7 +174,7 @@ pub(crate) fn runtime_path() -> Option { .get_or_init(|| { #[cfg(has_bundled_cli)] { - let dir = default_install_dir(CLI_VERSION); + let dir = default_install_dir(CLI_CACHE_ID); match install_runtime(&dir, build_time::RUNTIME_ARCHIVE) { Ok(path) => { info!(path = %path.display(), version = CLI_VERSION, "embedded runtime installed"); @@ -193,7 +196,7 @@ pub(crate) fn runtime_path() -> Option { 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) { + let install_dir = match runtime_install_dir(extract_dir, CLI_CACHE_ID) { Ok(dir) => dir, Err(e) => { warn!(error = %e, "embedded runtime install directory selection failed"); diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index b73e187d2d..b72df54291 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -599,6 +599,8 @@ pub mod rpc_methods { pub const SESSION_DIAGNOSTICS_READ: &str = "session.diagnostics.read"; /// `session.connectors.getCapabilities` pub const SESSION_CONNECTORS_GETCAPABILITIES: &str = "session.connectors.getCapabilities"; + /// `session.connectors.getAccount` + pub const SESSION_CONNECTORS_GETACCOUNT: &str = "session.connectors.getAccount"; /// `session.connectors.getStatus` pub const SESSION_CONNECTORS_GETSTATUS: &str = "session.connectors.getStatus"; /// `session.connectors.list` @@ -2952,6 +2954,25 @@ pub struct AuthIdentity { pub r#type: AuthInfoType, } +/// Credential-free identity metadata. +/// +///
+/// +/// **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 AuthIdentityMetadata { + /// Identity host. + pub host: String, + /// User login. + pub login: String, + /// Authentication type. + pub r#type: AuthInfoType, +} + /// Advance an in-flight login flow, optionally fulfilling an input-required step. /// ///
@@ -5626,6 +5647,12 @@ pub struct ConnectorCapabilities { pub max_poll_interval_ms: i64, /// Whether callers select a host-owned GitHub account through an opaque selection ID rather than supplying a provider token. pub opaque_account_selection: bool, + /// Whether getAccount is supported. Absence means false. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_account_selection: Option, + /// Whether reconcile accepts forceConnectorName. Absence means false. + #[serde(skip_serializing_if = "Option::is_none")] + pub targeted_reconcile: Option, } /// Credential-free Connector catalog entry. @@ -5644,12 +5671,21 @@ pub struct ConnectorCatalogEntry { pub description: Option, /// Untrusted display label from the service. pub display_name: String, + /// Optional catalog logo. + #[serde(skip_serializing_if = "Option::is_none")] + pub logo: Option, /// Canonical Connector name used by lifecycle methods. pub name: String, + /// Optional catalog release tag. + #[serde(skip_serializing_if = "Option::is_none")] + pub release_tag: Option, /// Opaque stable runtime IDs currently projected into the session for this Connector. pub runtime_server_ids: Vec, /// Current authoritative service connection state. pub status: ConnectorCatalogStatus, + /// Optional catalog tier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tier: Option, } /// Validated Connector catalog snapshot cached by the session. @@ -5846,6 +5882,68 @@ pub struct ConnectorReconcileRequest { pub refresh_catalog: Option, } +/// Extensible [`ConnectorReconcileRequest`], including inputs added after it was published. +/// +/// Required inputs are [`ConnectorReconcileOptions::new`] arguments; optional inputs have fluent setters. +/// Input-only: it serialises to the flat wire request and is not deserialisable. +/// +///
+/// +/// **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, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorReconcileOptions { + #[serde(flatten)] + legacy: ConnectorReconcileRequest, + #[serde(skip_serializing_if = "Option::is_none")] + force_connector_name: Option, +} + +impl ConnectorReconcileOptions { + /// Creates options with the required inputs. + pub fn new(account_id: impl Into) -> Self { + Self { + legacy: ConnectorReconcileRequest { + account_id: account_id.into(), + refresh_catalog: None, + }, + force_connector_name: None, + } + } + + /// When true, refresh the catalog before reconciling. A disabled Connector API performs no service request. + pub fn refresh_catalog(mut self, value: bool) -> Self { + self.legacy.refresh_catalog = Some(value); + self + } + + /// Optional Connector name to reinitialize. Requires the targetedReconcile capability. + pub fn force_connector_name(mut self, value: impl Into) -> Self { + self.force_connector_name = Some(value.into()); + self + } +} + +/// Session account selection. +/// +///
+/// +/// **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 ConnectorSessionAccount { + /// Opaque session-scoped account selection ID. + pub account_id: String, + /// Credential-free identity metadata. + pub auth_info: AuthIdentityMetadata, +} + /// Remote session connection parameters. /// ///
@@ -19698,35 +19796,6 @@ pub struct SessionActivity { pub has_active_work: bool, } -/// Current authentication information, or null when no authentication is active. -/// -///
-/// -/// **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 SessionAuthInfoResult { - /// Snapshot of the authenticated user's Copilot subscription info, if known - #[serde(skip_serializing_if = "Option::is_none")] - pub copilot_user: Option, - /// Name of the environment variable that supplied the credential, when applicable - #[serde(skip_serializing_if = "Option::is_none")] - pub env_var: Option, - /// Authentication host - pub host: String, - /// Authenticated login, when available - #[serde(skip_serializing_if = "Option::is_none")] - pub login: Option, - /// Opaque SDK GitHub credential registration backing this identity. Routing metadata only; never a credential. - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_id: Option, - /// Authentication type - pub r#type: AuthInfoType, -} - /// Internal GitHub login parameters. /// ///
@@ -31682,6 +31751,27 @@ pub struct SessionConnectorsGetCapabilitiesResult { pub max_poll_interval_ms: i64, /// Whether callers select a host-owned GitHub account through an opaque selection ID rather than supplying a provider token. pub opaque_account_selection: bool, + /// Whether getAccount is supported. Absence means false. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_account_selection: Option, + /// Whether reconcile accepts forceConnectorName. Absence means false. + #[serde(skip_serializing_if = "Option::is_none")] + pub targeted_reconcile: Option, +} + +/// 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 SessionConnectorsGetAccountParams { + /// Target session identifier + pub session_id: SessionId, } /// Identifies the target session. @@ -34722,6 +34812,16 @@ pub type CatalogResourceVersion = String; ///
pub type ClientMetadata = HashMap; +/// Session account selection, or null. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+pub type ConnectorSessionAccountResult = Option; + /// HTTP headers as a map from lowercased header name to a list of values. Multi-valued headers (e.g. Set-Cookie) preserve all values. /// ///
@@ -34762,6 +34862,16 @@ pub type McpPlanSecretReference = String; ///
pub type SandboxHostCapabilityName = String; +/// Current authentication information, or null when no authentication is active. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+pub type SessionAuthInfoResult = Option; + /// Ordered client metadata outcomes for the requested local sessions. /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index f6ac0d769e..b266b5db81 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -5961,6 +5961,34 @@ impl<'a> SessionRpcConnectors<'a> { Ok(serde_json::from_value(_value)?) } + /// Returns the session account selection, or null. + /// + /// Wire method: `session.connectors.getAccount`. + /// + /// # Returns + /// + /// Session account selection, or null. + /// + ///
+ /// + /// **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_account(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_CONNECTORS_GETACCOUNT, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } + /// Returns authoritative session Connector state from current availability, pinned account selection, cached catalog, and live MCP projection without performing a Connector service request. /// /// Wire method: `session.connectors.getStatus`. @@ -6223,6 +6251,41 @@ impl<'a> SessionRpcConnectors<'a> { Ok(serde_json::from_value(_value)?) } + /// Reconciles the authoritative cached or freshly requested Connector catalog into the session Connector MCP projection and returns live status. + /// + /// Wire method: `session.connectors.reconcile`. + /// + /// # Parameters + /// + /// * `params` - Requests authoritative Connector-to-MCP reconciliation for the pinned account. + /// + /// # Returns + /// + /// Authoritative session connector state. Account IDs are opaque routing identifiers and credentials are never included. + /// + ///
+ /// + /// **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. + /// + ///
+ /// + /// Accepts [`ConnectorReconcileOptions`], including inputs added after [`ConnectorReconcileRequest`]. + pub async fn reconcile_with_options( + &self, + params: ConnectorReconcileOptions, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CONNECTORS_RECONCILE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + /// Reconciles the authoritative Connector catalog into the session MCP projection during startup with a bounded deadline and fail-closed cleanup. /// /// Wire method: `session.connectors.reconcileForStartup`. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 613d99329e..9bca71a31f 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -7764,7 +7764,7 @@ pub struct SessionMcpServersLoadedData { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionMcpServerStatusChangedData { - /// Runtime configuration provenance for a failed connection, or unknown when unavailable. Additional string values may be introduced. + /// Runtime configuration provenance for a connected or failed server, or unknown when unavailable. Additional string values may be introduced. #[serde(skip_serializing_if = "Option::is_none")] pub config_source: Option, /// Error message if the server entered a failed state diff --git a/rust/src/lib.rs b/rust/src/lib.rs index da05be1482..ac57b50577 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,10 +11,17 @@ pub use ahp_host::{ AhpSessionResumeRequest, }; +// Outside tests, `cache_paths`'s only caller is `resolve::extracted_program`, which +// needs the build script to have extracted a CLI (`has_extracted_cli`). Without it the +// module is compiled only for its own unit tests, which exercise the pure path helpers; +// `extracted_runtime_install_dir` and `platform_cache_dir` then have no caller in the +// lib test unit (the build script reaches them through its own `#[path]` include). +#[cfg_attr(all(test, not(has_extracted_cli)), expect(dead_code))] #[cfg(all( feature = "runtime", not(feature = "bundled-cli"), - not(feature = "local-runtime") + not(feature = "local-runtime"), + any(test, has_extracted_cli) ))] mod cache_paths; /// Canvas declarations, provider callbacks, and host-side canvas RPC types. diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs index 1fbeb7c9f7..5cd04dd502 100644 --- a/rust/src/resolve.rs +++ b/rust/src/resolve.rs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + //! Internal resolution of the GitHub Copilot CLI binary. //! //! Resolution order: @@ -101,7 +103,7 @@ pub(crate) fn copilot_binary_with_extract_dir( /// `COPILOT_SKIP_CLI_DOWNLOAD`). /// /// The path is recomputed from the build-time-baked -/// `COPILOT_SDK_CLI_VERSION`, the OS-derived binary name, and the +/// `COPILOT_SDK_CLI_CACHE_ID`, the OS-derived binary name, and the /// optional `COPILOT_CLI_EXTRACT_DIR` env var. This must match /// the build script exactly; both use `cache_paths` so the convention /// cannot drift. We deliberately don't bake the resolved path into the @@ -110,8 +112,8 @@ pub(crate) fn copilot_binary_with_extract_dir( /// and prevents copying `target/` between hosts. #[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] fn extracted_program(use_runtime_wrapper: bool) -> Option { - let version = env!("COPILOT_SDK_CLI_VERSION"); - let dir = crate::cache_paths::extracted_runtime_install_dir(version); + let cache_identity = env!("COPILOT_SDK_CLI_CACHE_ID"); + let dir = crate::cache_paths::extracted_runtime_install_dir(cache_identity); let path = dir.join(if use_runtime_wrapper { runtime_binary_name() diff --git a/rust/src/session.rs b/rust/src/session.rs index 82d1e2cc89..fc837fd0cc 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -38,7 +38,8 @@ use crate::types::{ GetMessagesResponse, MessageOptions, PermissionRequestData, RequestId, ResumeSessionConfig, ResumeSessionResult, SectionOverride, SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, ToolResult, - ToolResultExpanded, TraceContext, UiInputOptions, ensure_attachment_display_names, + ToolResultExpanded, TraceContext, TranscriptRecovery, UiInputOptions, + ensure_attachment_display_names, }; use crate::{ Client, Error, ErrorKind, JsonRpcResponse, SessionErrorKind, SessionEventNotification, @@ -490,6 +491,7 @@ impl CreateEventLoop { /// unregisters from the router as a best-effort safety net. pub struct Session { ahp_creation_config: ParkingLotMutex>, + transcript_recovery: Option, id: SessionId, cwd: PathBuf, workspace_path: Option, @@ -594,6 +596,11 @@ impl Session { self.workspace_path.as_deref() } + /// Transcript repair proposed when this session was resumed, if any. + pub fn transcript_recovery(&self) -> Option<&TranscriptRecovery> { + self.transcript_recovery.as_ref() + } + /// Remote session URL, if the session is running remotely. pub fn remote_url(&self) -> Option<&str> { self.remote_url.as_deref() @@ -1833,6 +1840,7 @@ impl Client { ); let session = Session { ahp_creation_config: ParkingLotMutex::new(ahp_creation_config), + transcript_recovery: None, id: session_id, cwd: self.cwd().clone(), workspace_path: create_result.workspace_path, @@ -1877,6 +1885,7 @@ impl Client { shutdown: CancellationToken, ) -> Result { let total_start = Instant::now(); + let mode = self.inner.mode; let ahp_creation_config = if self .inner .ahp_host_sessions @@ -1894,7 +1903,6 @@ impl Client { if let Some(transforms) = config.system_message_transform.clone() { inject_transform_sections_resume(&mut config, transforms.as_ref()); } - let mode = self.inner.mode; if mode == crate::ClientMode::Empty && config.available_tools.is_none() { return Err(Error::with_message( ErrorKind::InvalidConfig, @@ -2133,6 +2141,7 @@ impl Client { ); let session = Session { ahp_creation_config: ParkingLotMutex::new(ahp_creation_config), + transcript_recovery: resume_result.transcript_recovery, id: session_id, cwd: self.cwd().clone(), workspace_path: resume_result.workspace_path, diff --git a/rust/src/types.rs b/rust/src/types.rs index 5c5d5b17b6..de84144aca 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -3693,9 +3693,15 @@ pub struct ResumeSessionConfig { /// was dropped. Use this together with [`Client::force_stop`] to hand /// off a session from one process to another without losing in-flight /// work. + /// When omitted or `false` (the default), work still pending on resume + /// is treated as interrupted; completed tool results already recorded by + /// the runtime are preserved. /// /// [`Client::force_stop`]: crate::Client::force_stop pub continue_pending_work: Option, + /// Permit recovery of a damaged transcript on resume. Defaults to `true` + /// in all modes when unset. Set `false` to reject recovery. + pub allow_transcript_recovery: Option, /// Optional permission-request handler. See /// [`SessionConfig::permission_handler`]. pub permission_handler: Option>, @@ -4013,6 +4019,7 @@ impl ResumeSessionConfig { managed_settings: self.managed_settings, suppress_resume_event: self.suppress_resume_event, continue_pending_work: self.continue_pending_work, + allow_transcript_recovery: self.allow_transcript_recovery, }; let runtime = SessionConfigRuntime { @@ -4117,6 +4124,7 @@ impl ResumeSessionConfig { session_fs_provider: None, suppress_resume_event: None, continue_pending_work: None, + allow_transcript_recovery: None, permission_handler: None, elicitation_handler: None, mcp_auth_handler: None, @@ -4727,11 +4735,19 @@ impl ResumeSessionConfig { /// was dropped. Use this together with /// [`Client::force_stop`](crate::Client::force_stop) to hand off a /// session from one process to another without losing in-flight work. + /// When `false` (the default), pending work is treated as interrupted on + /// resume; already-recorded tool results are preserved. pub fn with_continue_pending_work(mut self, continue_pending: bool) -> Self { self.continue_pending_work = Some(continue_pending); self } + /// Set [`Self::allow_transcript_recovery`]. + pub fn with_allow_transcript_recovery(mut self, allow: bool) -> Self { + self.allow_transcript_recovery = Some(allow); + self + } + /// Set [`Self::skip_custom_instructions`]. pub fn with_skip_custom_instructions(mut self, value: bool) -> Self { self.skip_custom_instructions = Some(value); @@ -4887,6 +4903,18 @@ pub struct CreateSessionResult { pub capabilities: Option, } +/// Details of transcript repair planned during resume. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptRecovery { + /// Planned backup path; the backup is written on the next append. + pub planned_backup_path: String, + /// One-based physical line numbers removed from the transcript. + pub invalid_line_numbers: Vec, + /// Whether a valid session.start event was moved to the beginning. + pub session_start_moved: bool, +} + /// Response from `session.resume`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -4910,6 +4938,9 @@ pub(crate) struct ResumeSessionResult { skip_serializing_if = "Option::is_none" )] pub open_canvases: Option>, + /// Recovery performed in memory while loading the session. + #[serde(default)] + pub transcript_recovery: Option, } /// Severity level for [`Session::log`](crate::session::Session::log) messages. @@ -7487,6 +7518,38 @@ mod tests { assert!(json.get("continuePendingWork").is_none()); } + #[test] + fn resume_policy_and_recovery_report_round_trip() { + let config = ResumeSessionConfig::new(SessionId::from("sess-1")) + .with_allow_transcript_recovery(false); + let (wire, _) = config.into_wire().unwrap(); + let value = serde_json::to_value(&wire).unwrap(); + assert_eq!(value["allowTranscriptRecovery"], false); + + let (wire, _) = ResumeSessionConfig::new(SessionId::from("sess-2")) + .into_wire() + .unwrap(); + assert!( + serde_json::to_value(&wire) + .unwrap() + .get("allowTranscriptRecovery") + .is_none() + ); + + let result: crate::types::ResumeSessionResult = serde_json::from_value(serde_json::json!({ + "sessionId": "sess-1", + "transcriptRecovery": { + "plannedBackupPath": "events.jsonl.backup", + "invalidLineNumbers": [2], + "sessionStartMoved": false + } + })) + .unwrap(); + let recovery = result.transcript_recovery.unwrap(); + assert_eq!(recovery.invalid_line_numbers, vec![2]); + assert_eq!(recovery.planned_backup_path, "events.jsonl.backup"); + } + #[test] fn session_configs_serialize_additional_directories() { let create = SessionConfig::default().with_additional_directories([ diff --git a/rust/src/wire.rs b/rust/src/wire.rs index f5281476a5..61e7d7c44f 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -374,6 +374,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 allow_transcript_recovery: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub feature_flags: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub exp_assignments: Option, diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index d4ea6cd7dd..326de092db 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -4,8 +4,10 @@ #![allow(clippy::unwrap_used)] use github_copilot_sdk::rpc::{ - AcceptedEnqueueCommandResult, ConnectorAccountRequest, ConnectorCatalogStatus, - ConnectorConnectRequest, ConnectorContinueRequest, ConnectorReconcileRequest, + AcceptedEnqueueCommandResult, AuthIdentityMetadata, AuthInfoType, ConnectorAccountRequest, + ConnectorCapabilities, ConnectorCatalogEntry, ConnectorCatalogStatus, ConnectorConnectRequest, + ConnectorConnectResult, ConnectorContinueRequest, ConnectorReconcileOptions, + ConnectorReconcileRequest, ConnectorSessionAccount, ConnectorSessionAccountResult, EnqueueCommandResult, Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, McpDisableRequest, McpEnableOptions, McpEnableRequest, McpInstallationOperationStatus, @@ -363,6 +365,99 @@ fn connector_request_dtos_use_public_camel_case_wire_fields() { "refreshCatalog": true, }) ); + + let targeted = ConnectorReconcileOptions::new("account-1").force_connector_name("github"); + assert_eq!( + serde_json::to_value(targeted).unwrap(), + serde_json::json!({ + "accountId": "account-1", + "forceConnectorName": "github", + }) + ); +} + +#[test] +fn connector_session_account_is_nullable_and_credential_free() { + let account = ConnectorSessionAccount { + account_id: "session-account-1".to_string(), + auth_info: AuthIdentityMetadata { + r#type: AuthInfoType::Token, + host: "github.com".to_string(), + login: "alice".to_string(), + }, + }; + let expected = serde_json::json!({ + "accountId": "session-account-1", + "authInfo": { "type": "token", "host": "github.com", "login": "alice" }, + }); + assert_eq!(serde_json::to_value(&account).unwrap(), expected); + let decoded: ConnectorSessionAccountResult = serde_json::from_value(expected).unwrap(); + assert_eq!(decoded.unwrap().account_id, account.account_id); + let unavailable: ConnectorSessionAccountResult = + serde_json::from_value(serde_json::Value::Null).unwrap(); + assert!(unavailable.is_none()); + assert_eq!( + serde_json::to_value(unavailable).unwrap(), + serde_json::Value::Null + ); +} + +#[test] +fn connector_optional_capabilities_are_not_enabled_by_older_runtimes() { + for flag in [None, Some(false), Some(true)] { + let mut value = serde_json::json!({ + "apiVersion": 1, + "availability": "enabled", + "consentContinuation": true, + "opaqueAccountSelection": true, + "maxPollAttempts": 10, + "maxPollIntervalMs": 5_000, + "maxDeadlineMs": 60_000, + }); + if let Some(flag) = flag { + value["sessionAccountSelection"] = serde_json::json!(flag); + value["targetedReconcile"] = serde_json::json!(flag); + } + let capabilities: ConnectorCapabilities = serde_json::from_value(value).unwrap(); + assert_eq!(capabilities.session_account_selection, flag); + assert_eq!(capabilities.targeted_reconcile, flag); + assert_eq!( + capabilities.session_account_selection == Some(true), + flag == Some(true) + ); + assert_eq!( + capabilities.targeted_reconcile == Some(true), + flag == Some(true) + ); + } +} + +#[test] +fn connector_catalog_presentation_metadata_is_optional() { + let legacy = serde_json::json!({ + "name": "github", + "displayName": "GitHub", + "status": "connected", + "runtimeServerIds": ["connector-github"], + }); + let entry: ConnectorCatalogEntry = serde_json::from_value(legacy.clone()).unwrap(); + assert!(entry.logo.is_none()); + assert!(entry.tier.is_none()); + assert!(entry.release_tag.is_none()); + assert_eq!(serde_json::to_value(entry).unwrap(), legacy); + + let mut decorated = legacy; + decorated["logo"] = serde_json::json!("https://example.com/github.svg"); + decorated["tier"] = serde_json::json!("standard"); + decorated["releaseTag"] = serde_json::json!("preview"); + let entry: ConnectorCatalogEntry = serde_json::from_value(decorated.clone()).unwrap(); + assert_eq!( + entry.logo.as_deref(), + Some("https://example.com/github.svg") + ); + assert_eq!(entry.tier.as_deref(), Some("standard")); + assert_eq!(entry.release_tag.as_deref(), Some("preview")); + assert_eq!(serde_json::to_value(entry).unwrap(), decorated); } #[test] @@ -376,6 +471,23 @@ fn connector_catalog_unknown_wire_value_has_a_distinct_variant() { assert_eq!(future_status, ConnectorCatalogStatus::Unknown); } +#[test] +fn connector_connect_results_do_not_treat_unknown_outcomes_as_connected() { + let future_result = serde_json::json!({ + "kind": "future_outcome", + "continuationId": "continuation-1", + "consentUrl": "https://example.com/consent", + "status": { + "apiVersion": 1, + "availability": "enabled", + "runtimeServers": [], + "pendingConnections": 0 + } + }); + + assert!(serde_json::from_value::(future_result).is_err()); +} + #[test] fn model_allowed_models_request_and_result_preserve_contract_fields() { let replace = ModelSetAllowedModelsRequest { diff --git a/rust/tests/build_acquisition.rs b/rust/tests/build_acquisition.rs new file mode 100644 index 0000000000..8f0875a90b --- /dev/null +++ b/rust/tests/build_acquisition.rs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +#![cfg(test)] +#![allow(clippy::unwrap_used)] + +#[path = "../build/in_process.rs"] +mod implementation; diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index 97815a326e..aabe441ad9 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + //! Tests for the build-time and runtime CLI provisioning path. //! //! Covers the `COPILOT_CLI_PATH` env override, the build-time-extracted @@ -109,19 +111,19 @@ async fn stale_env_override_falls_through() { /// 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 +/// `COPILOT_SDK_CLI_CACHE_ID` + the OS-derived binary name. This test /// mirrors that convention and asserts the file is on disk where the /// resolver expects to find it. #[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] #[test] fn extracted_binary_present_at_conventional_path() { - let version = env!("COPILOT_SDK_CLI_VERSION"); + let cache_identity = env!("COPILOT_SDK_CLI_CACHE_ID"); let binary = if cfg!(windows) { "copilot-runtime.exe" } else { "copilot-runtime" }; - let sanitized = sanitize_version_for_test(version); + let sanitized = sanitize_cache_identity_for_test(cache_identity); let path = dirs::cache_dir() .expect("platform cache dir") .join("github-copilot-sdk") @@ -136,8 +138,8 @@ fn extracted_binary_present_at_conventional_path() { } #[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] -fn sanitize_version_for_test(version: &str) -> String { - version +fn sanitize_cache_identity_for_test(cache_identity: &str) -> String { + cache_identity .chars() .map(|c| match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, @@ -222,7 +224,11 @@ fn pin_file_when_present_is_well_formed() { continue; } let contents = std::fs::read_to_string(&pin).expect("read CLI version snapshot"); - let mut saw_version = false; + let version = contents + .lines() + .filter_map(|line| line.split_once('=')) + .find_map(|(key, value)| (key.trim() == "version").then_some(value.trim())) + .unwrap_or_else(|| panic!("{filename} missing `version=` line")); let mut package_count = 0; for raw in contents.lines() { let line = raw.trim(); @@ -234,7 +240,19 @@ fn pin_file_when_present_is_well_formed() { .unwrap_or_else(|| panic!("malformed line: {raw:?}")); assert!(!value.trim().is_empty(), "empty value for key {key:?}"); if key.trim() == "version" { - saw_version = true; + continue; + } else if key.trim() == "release-url" { + assert!( + value.trim() + == format!( + "https://github.com/github/copilot-cli/releases/download/v{version}" + ) + || value.trim() + == format!( + "https://github.com/github/copilot-sdk/releases/download/runtime-{version}" + ), + "unexpected release URL in {filename}" + ); } else { assert_eq!( value.trim().len(), @@ -248,7 +266,6 @@ fn pin_file_when_present_is_well_formed() { package_count += 1; } } - assert!(saw_version, "{filename} missing `version=` line"); assert_eq!( package_count, 8, "{filename} has incomplete platform hashes" diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index 37b5383583..c516f84be6 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -9,10 +9,10 @@ use github_copilot_sdk::session_events::{ }; use github_copilot_sdk::{ AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, - AttachmentSelectionRange, CliProgram, Client, ClientOptions, CloudSessionOptions, + AttachmentSelectionRange, CliProgram, Client, ClientMode, ClientOptions, CloudSessionOptions, CloudSessionRepository, CopilotExpAssignmentResponse, DeliveryMode, ExtensionInfo, GitHubReferenceType, MessageOptions, MessageSource, ProviderConfig, ResumeSessionConfig, - SessionConfig, SessionId, Transport, + SessionConfig, SessionId, TranscriptRecovery, Transport, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -215,6 +215,50 @@ async fn should_forward_singular_provider_configuration_on_session_creation() { assert_eq!(provider["headers"]["x-provider"], json!("rust")); } +#[tokio::test] +async fn resume_transcript_recovery_defaults_overrides_and_projection() { + for mode in [ClientMode::Empty, ClientMode::CopilotCli] { + for choice in [None, Some(false), Some(true)] { + let fake = FakeCli::new(); + let reports_recovery = choice.unwrap_or(true); + let behavior = if reports_recovery { + "transcript-recovery" + } else { + "normal" + }; + let client = Client::start( + fake.client_options_with_behavior("recovery-client-token", behavior) + .with_mode(mode) + .with_base_directory(fake.path("state")), + ) + .await + .expect("start fake CLI client"); + let mut config = ResumeSessionConfig::new("recovery-session".into()) + .with_available_tools(Vec::::new()); + if let Some(allow) = choice { + config = config.with_allow_transcript_recovery(allow); + } + let session = client.resume_session(config).await.expect("resume session"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + + let request = fake.captured_request("session.resume"); + let expected = choice.map(Value::Bool); + assert_eq!( + request.params.get("allowTranscriptRecovery"), + expected.as_ref(), + "mode: {mode:?}, choice: {choice:?}" + ); + let recovery = reports_recovery.then(|| TranscriptRecovery { + planned_backup_path: "recovery-backup.jsonl".into(), + invalid_line_numbers: vec![3, 5], + session_start_moved: true, + }); + assert_eq!(session.transcript_recovery(), recovery.as_ref()); + } + } +} + #[tokio::test] async fn should_forward_advanced_session_resume_options_to_the_cli() { let fake = FakeCli::new(); @@ -1138,7 +1182,15 @@ function handleMessage(message) { } if (message.method === "session.resume") { const sessionId = (message.params && message.params.sessionId) || "fake-session"; - writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null, openCanvases: [] }); + const result = { sessionId, workspacePath: null, capabilities: null, openCanvases: [] }; + if (behavior === "transcript-recovery") { + result.transcriptRecovery = { + plannedBackupPath: "recovery-backup.jsonl", + invalidLineNumbers: [3, 5], + sessionStartMoved: true, + }; + } + writeResponse(message.id, result); return; } if (message.method === "session.options.update") { diff --git a/rust/tests/fixtures/connector-runtime/budget.ts b/rust/tests/fixtures/connector-runtime/budget.ts new file mode 100644 index 0000000000..21650d1b0a --- /dev/null +++ b/rust/tests/fixtures/connector-runtime/budget.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** Wall-clock budget the harness allows a single fixture child process. */ +export const CASE_TIMEOUT_MS = 210_000; + +/** Slack for process spawn, MCP/API server startup, and report writing. */ +const MATRIX_OVERHEAD_MS = 30_000; + +export const transports = (process.env.CONNECTOR_LOCAL_TRANSPORTS ?? "stdio,inprocess").split(","); +export const cases = (process.env.CONNECTOR_LOCAL_CASES ?? "static,rotation,scope,targeted,disabled").split(","); + +/** + * The matrix runs sequentially, so the suite timeout must cover every case's own + * budget rather than a single case's. + */ +export const matrixTimeoutMs = transports.length * cases.length * CASE_TIMEOUT_MS + MATRIX_OVERHEAD_MS; diff --git a/rust/tests/fixtures/connector-runtime/fixture.test.ts b/rust/tests/fixtures/connector-runtime/fixture.test.ts new file mode 100644 index 0000000000..0317b88770 --- /dev/null +++ b/rust/tests/fixtures/connector-runtime/fixture.test.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { it } from "../../../../nodejs/node_modules/vitest/dist/index.js"; + +import { matrixTimeoutMs } from "./budget.ts"; + +it( + "exercises the real public Rust SDK Connector contract", + async () => { + await import("./fixture.ts"); + }, + matrixTimeoutMs, +); diff --git a/rust/tests/fixtures/connector-runtime/fixture.ts b/rust/tests/fixtures/connector-runtime/fixture.ts new file mode 100644 index 0000000000..5c6f0a5b18 --- /dev/null +++ b/rust/tests/fixtures/connector-runtime/fixture.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { appendFile, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { CASE_TIMEOUT_MS, cases, transports } from "./budget.ts"; + +const runtimeRoot = process.env.CONNECTOR_FIXTURE_RUNTIME_ROOT; +const binary = process.env.CONNECTOR_LOCAL_BINARY; +assert(runtimeRoot && binary, "Supply the runtime fixture root and compiled SDK test binary"); +const { TestMcpHttpServer } = await import( + pathToFileURL(join(runtimeRoot, "test/cli/e2e/harness/testMcpHttpServer.ts")).href +); +const { getSharedCA } = await import(pathToFileURL(join(runtimeRoot, "test/cli/e2e/harness/certUtils.ts")).href); + +const tokens = { + first: "ghu_connector_session_first", + rotated: "ghu_connector_session_rotated", + other: "ghu_connector_session_other_user", + repository: "ghu_connector_repository_identity", +}; + +async function runCase(transport: string, testCase: string) { + const caPem = getSharedCA().certPem; + const directory = await mkdtemp(join(tmpdir(), "sdk-connector-local-")); + const home = join(directory, "home"); + await mkdir(home); + const ca = join(directory, "ca.pem"); + await writeFile(ca, caPem); + const mail = new TestMcpHttpServer(true, tokens.first); + const calendar = new TestMcpHttpServer(true, tokens.first); + const ordinary = new TestMcpHttpServer(true); + let mailUrl = ""; + let calendarUrl = ""; + let ordinaryUrl = ""; + let reads = 0; + let writes = 0; + const generations: string[] = []; + const userGenerations: string[] = []; + let expandedScope = false; + let apiUrl: string; + const mcpStatus = (server: InstanceType) => ({ + initializations: server.connectionInitializationCount, + lists: server.toolsListRequestCount, + unauthorized: server.unauthorizedRequestCount, + active: server.activeConnectionCount, + }); + const status = () => ({ + reads, + writes, + generations, + userGenerations, + mail: mcpStatus(mail), + calendar: mcpStatus(calendar), + ordinary: mcpStatus(ordinary), + }); + const plugin = (name: string, url: string) => ({ + name, + logo: "https://images.example/connector.svg", + metadata: { displayName: name, tier: "standard", releaseTag: "preview" }, + connection: { status: "connected" }, + mcpServers: { mcpServers: { [name]: { type: "http", url } } }, + }); + const api = createServer(async (request, response) => { + try { + response.setHeader("content-type", "application/json"); + if (request.url === "/local-test-control") { + if (request.method === "POST") { + let body = ""; + for await (const chunk of request) body += chunk; + const update = JSON.parse(body); + if (update.expandedScope !== undefined) expandedScope = update.expandedScope; + if (update.credential !== undefined) { + const credential = tokens[update.credential as keyof typeof tokens]; + assert(credential); + mail.setRequiredBearerToken(credential); + calendar.setRequiredBearerToken(credential); + } + } + response.end(JSON.stringify(status())); + return; + } + const header = request.headers.authorization ?? ""; + const token = header.slice(header.indexOf(" ") + 1); + const generation = Object.entries(tokens).find(([, value]) => value === token)?.[0] ?? "unknown"; + if (request.url?.startsWith("/copilot_internal/user")) { + userGenerations.push(generation); + if (generation === "unknown") { + response.writeHead(401).end(JSON.stringify({ message: "Bad credentials" })); + } else { + response.end( + JSON.stringify({ + login: + generation === "repository" + ? "repository-user" + : generation === "other" + ? "hubot" + : "octocat", + copilot_plan: "individual_pro", + is_mcp_enabled: true, + endpoints: { api: apiUrl }, + }), + ); + } + return; + } + if (request.url === "/copilot-connectors/api/v1/plugins") { + reads++; + generations.push(generation); + if (expandedScope && generation === "first") { + response + .writeHead(403, { + "x-github-request-id": "LOCAL-CONNECTOR-SCOPE-FIXTURE", + "x-accepted-oauth-scopes": "write:plugin_gateway_connections", + "x-oauth-scopes": "read:user", + }) + .end(JSON.stringify({ message: "insufficient OAuth scope" })); + } else { + response.end( + JSON.stringify({ plugins: [plugin("mail", mailUrl), plugin("calendar", calendarUrl)] }), + ); + } + return; + } + if ( + request.url?.startsWith("/copilot-connectors/api/v1/connectors/managed/") && + ["PUT", "DELETE"].includes(request.method ?? "") + ) { + writes++; + response.end("{}"); + return; + } + response.writeHead(404).end("{}"); + } catch (error) { + console.error("Local fixture request failed", error); + if (!response.headersSent) response.writeHead(500); + response.end(JSON.stringify({ message: "Local fixture failure" })); + } + }); + try { + // Started inside the try so a partial startup failure still reaches the cleanup below, + // and one at a time so no sibling can begin listening after that cleanup has run. + mailUrl = await mail.start(); + calendarUrl = await calendar.start(); + ordinaryUrl = await ordinary.start(); + await new Promise((resolve, reject) => { + api.once("error", reject); + api.listen(0, "127.0.0.1", resolve); + }); + const address = api.address(); + assert(address && typeof address !== "string"); + apiUrl = `http://127.0.0.1:${address.port}`; + await new Promise((resolve, reject) => { + const child = spawn(binary!, [], { + cwd: directory, + stdio: "inherit", + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + COPILOT_HOME: join(directory, "copilot-home"), + COPILOT_DEBUG_GITHUB_API_URL: apiUrl, + COPILOT_API_URL: apiUrl, + COPILOT_GITHUB_TOKEN: tokens.repository, + GH_TOKEN: tokens.repository, + GITHUB_TOKEN: tokens.repository, + COPILOT_SDK_AUTH_TOKEN: "", + NODE_EXTRA_CA_CERTS: ca, + SSL_CERT_FILE: ca, + CURL_CA_BUNDLE: ca, + NO_PROXY: "localhost,127.0.0.1,::1", + no_proxy: "localhost,127.0.0.1,::1", + CONNECTOR_LOCAL_API: apiUrl, + CONNECTOR_LOCAL_DIRECTORY: directory, + CONNECTOR_LOCAL_ORDINARY: ordinaryUrl, + CONNECTOR_LOCAL_CASE: testCase, + CONNECTOR_LOCAL_TRANSPORT: transport, + }, + }); + // Escalate to SIGKILL and only settle once the child is gone, so cleanup never + // removes the working directory or servers an orphan is still using. + let timedOut = false; + let kill: ReturnType | undefined; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + kill = setTimeout(() => child.kill("SIGKILL"), 5_000); + }, CASE_TIMEOUT_MS); + const settle = () => { + clearTimeout(timeout); + if (kill !== undefined) clearTimeout(kill); + }; + child.once("error", (error) => { + settle(); + reject(error); + }); + child.once("exit", (code, signal) => { + settle(); + if (timedOut) { + reject(new Error(`Local integration timeout: ${transport}/${testCase}`)); + return; + } + if (code === 0) resolve(); + else + reject( + new Error(`Local integration failed: ${transport}/${testCase}, exit ${code}, signal ${signal}`), + ); + }); + }); + const report = JSON.stringify({ transport, testCase, ...status() }); + console.log(report); + if (process.env.CONNECTOR_LOCAL_REPORT) { + await appendFile(process.env.CONNECTOR_LOCAL_REPORT, `${report}\n`); + } + } catch (error) { + if (process.env.CONNECTOR_LOCAL_REPORT) { + await appendFile( + process.env.CONNECTOR_LOCAL_REPORT, + `${JSON.stringify({ transport, testCase, passed: false, ...status() })}\n`, + ); + } + throw error; + } finally { + api.closeAllConnections(); + await Promise.all([ + mail.stop(), + calendar.stop(), + ordinary.stop(), + // Closing a server that never listened reports ERR_SERVER_NOT_RUNNING, which would + // mask the startup failure that skipped `api.listen` in the first place. + api.listening + ? new Promise((resolve, reject) => api.close((error) => (error ? reject(error) : resolve()))) + : Promise.resolve(), + ]); + await rm(directory, { recursive: true, force: true }); + } +} + +for (const transport of transports) { + for (const testCase of cases) await runCase(transport, testCase); +} diff --git a/rust/tests/fixtures/connector-runtime/package.json b/rust/tests/fixtures/connector-runtime/package.json new file mode 100644 index 0000000000..e986b24bba --- /dev/null +++ b/rust/tests/fixtures/connector-runtime/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/rust/tests/fixtures/connector-runtime/src/main.rs b/rust/tests/fixtures/connector-runtime/src/main.rs new file mode 100644 index 0000000000..309a7b18ae --- /dev/null +++ b/rust/tests/fixtures/connector-runtime/src/main.rs @@ -0,0 +1,531 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use github_copilot_sdk::github_token::{ + GitHubToken, GitHubTokenProviderArgs, GitHubTokenProviderResult, GitHubTokenRequestReason, +}; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::rpc::{ + ConnectorAccountRequest, ConnectorAuthorizationScope, ConnectorAvailability, + ConnectorConnectRequest, ConnectorConnectResult, ConnectorMcpStatus, ConnectorReconcileOptions, + ConnectorReconcileRequest, ConnectorSessionAccount, ConnectorStatus, McpDisableRequest, + McpListToolsRequest, +}; +use github_copilot_sdk::session::Session; +use github_copilot_sdk::{ + Client, ClientOptions, LogLevel, McpHttpServerConfig, McpServerConfig, SessionConfig, Transport, +}; +use serde_json::{Value, json}; + +type Result = std::result::Result>; +const FIRST: &str = "ghu_connector_session_first"; +const ROTATED: &str = "ghu_connector_session_rotated"; +const OTHER: &str = "ghu_connector_session_other_user"; +const REPOSITORY: &str = "ghu_connector_repository_identity"; + +fn setting(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("Missing test setting {name}")) +} + +fn persistent_config(label: &str) -> Result>> { + let path = PathBuf::from(setting("CONNECTOR_LOCAL_DIRECTORY")) + .join(label) + .join("config.json"); + match std::fs::read(path) { + Ok(contents) => Ok(Some(contents)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +async fn control(update: Option) -> Result { + let url = format!("{}/local-test-control", setting("CONNECTOR_LOCAL_API")); + let client = reqwest::Client::new(); + let response = match update { + Some(update) => { + client + .post(url) + .header("content-type", "application/json") + .body(serde_json::to_vec(&update)?) + .send() + .await? + } + None => client.get(url).send().await?, + }; + Ok(serde_json::from_str( + &response.error_for_status()?.text().await?, + )?) +} + +async fn client(label: &str, stdio: bool) -> Result { + let transport = if stdio || setting("CONNECTOR_LOCAL_TRANSPORT") == "stdio" { + Transport::Stdio + } else { + Transport::InProcess + }; + Ok(Client::start( + ClientOptions::new() + .with_transport(transport) + .with_log_level(LogLevel::Error) + .with_use_logged_in_user(false) + .with_base_directory(PathBuf::from(setting("CONNECTOR_LOCAL_DIRECTORY")).join(label)), + ) + .await?) +} + +fn config(enabled: bool) -> SessionConfig { + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_feature_flags(HashMap::from([ + ("MANAGED_MCP_SERVERS".to_owned(), enabled), + ("CONNECTORS".to_owned(), true), + ])) + .with_request_extensions(false) + .with_disabled_mcp_servers(["github-mcp-server"]) + .with_working_directory(setting("CONNECTOR_LOCAL_DIRECTORY")) + .with_mcp_servers( + [( + "ordinary".to_owned(), + McpServerConfig::Http(McpHttpServerConfig { + url: setting("CONNECTOR_LOCAL_ORDINARY"), + tools: Some(vec!["*".to_owned()]), + ..Default::default() + }), + )] + .into_iter() + .collect(), + ) +} + +async fn account(session: &Session) -> Result { + let capabilities = session.rpc().connectors().get_capabilities().await?; + assert_eq!(capabilities.availability, ConnectorAvailability::Enabled); + assert_eq!(capabilities.session_account_selection, Some(true)); + assert_eq!(capabilities.targeted_reconcile, Some(true)); + let before = control(None).await?; + let selected = session + .rpc() + .connectors() + .get_account() + .await? + .ok_or("Expected an eligible session account")?; + let after = control(None).await?; + assert_eq!(before["reads"], after["reads"]); + assert_eq!(selected.auth_info.login, "octocat"); + assert_eq!(selected.auth_info.host, "https://github.com"); + let public = serde_json::to_value(&selected)?; + assert_eq!( + public + .as_object() + .expect("serialized account is a JSON object") + .len(), + 2 + ); + assert_eq!( + public["authInfo"] + .as_object() + .expect("authInfo is a JSON object") + .len(), + 3 + ); + assert_no_secrets(&public); + Ok(selected) +} + +fn assert_no_secrets(value: &Value) { + let text = serde_json::to_string(value).expect("value serializes to JSON"); + for forbidden in [ + FIRST, + ROTATED, + OTHER, + REPOSITORY, + "registrationId", + "accessToken", + "127.0.0.1", + ] { + assert!( + !text.contains(forbidden), + "Public Connector state leaked a private value" + ); + } +} + +async fn reconcile( + session: &Session, + account_id: &str, + force: Option<&str>, +) -> Result { + let connectors = session.rpc().connectors(); + Ok(match force { + Some(force) => { + connectors + .reconcile_with_options( + ConnectorReconcileOptions::new(account_id) + .refresh_catalog(true) + .force_connector_name(force), + ) + .await? + } + None => { + connectors + .reconcile(ConnectorReconcileRequest { + account_id: account_id.to_owned(), + refresh_catalog: Some(true), + }) + .await? + } + }) +} + +fn server_id(status: &ConnectorStatus, name: &str) -> String { + status + .runtime_servers + .iter() + .find(|server| server.connector_name == name) + .unwrap_or_else(|| panic!("Missing projected Connector {name}")) + .runtime_server_id + .clone() +} + +fn check_connected(status: &ConnectorStatus) { + assert!( + status + .runtime_servers + .iter() + .all(|server| server.status == ConnectorMcpStatus::Connected) + ); + assert!( + status + .runtime_servers + .iter() + .any(|server| server.connector_name == "mail") + ); + let catalog = status.catalog.as_ref().expect("catalog"); + assert_eq!(catalog.connectors[0].tier.as_deref(), Some("standard")); + assert_eq!( + catalog.connectors[0].release_tag.as_deref(), + Some("preview") + ); + assert!(catalog.connectors[0].logo.is_some()); + assert_no_secrets(&serde_json::to_value(status).expect("status serializes to JSON")); +} + +async fn static_identity() -> Result<()> { + let client = client("static", false).await?; + let config_before = persistent_config("static")?; + let accounts_before = serde_json::to_value(client.rpc().account().get_all_users().await?)?; + let session = client + .create_session(config(true).with_github_token(FIRST)) + .await?; + let selected = account(&session).await?; + assert!( + session + .rpc() + .connectors() + .get_status() + .await? + .account_id + .is_none() + ); + let second = client + .create_session(config(true).with_github_token(FIRST)) + .await?; + let other = account(&second).await?; + assert_ne!(selected.account_id, other.account_id); + let before = control(None).await?; + assert!( + second + .rpc() + .connectors() + .refresh(ConnectorAccountRequest { + account_id: selected.account_id.clone() + }) + .await + .is_err() + ); + assert_eq!(before["reads"], control(None).await?["reads"]); + let status = reconcile(&session, &selected.account_id, None).await?; + check_connected(&status); + assert_eq!(control(None).await?["generations"], json!(["first"])); + assert_eq!(control(None).await?["writes"], json!(0)); + assert_eq!( + serde_json::to_value(client.rpc().account().get_all_users().await?)?, + accounts_before + ); + second.disconnect().await?; + session.disconnect().await?; + client.stop().await?; + assert!( + persistent_config("static")? == config_before, + "Session credentials changed persistent account configuration" + ); + Ok(()) +} + +async fn provider_case(scope: bool) -> Result<()> { + if scope { + control(Some(json!({ "expandedScope": true }))).await?; + } + let mode = Arc::new(AtomicUsize::new(0)); + let reasons = Arc::new(Mutex::new(Vec::new())); + let provider = { + let mode = mode.clone(); + let reasons = reasons.clone(); + Arc::new(move |args: GitHubTokenProviderArgs| { + let mode = mode.clone(); + let reasons = reasons.clone(); + async move { + reasons + .lock() + .expect("token reason lock is not poisoned") + .push(args.reason); + let token = match mode.load(Ordering::SeqCst) { + 0 => FIRST, + 1 => ROTATED, + _ => OTHER, + }; + Ok(GitHubTokenProviderResult::Token(GitHubToken::new( + token, 28_800, + ))) + } + }) + }; + let client = client("provider", false).await?; + let config_before = persistent_config("provider")?; + let accounts_before = serde_json::to_value(client.rpc().account().get_all_users().await?)?; + let session = client + .create_session(config(true).with_github_token_provider(provider)) + .await?; + let selected = account(&session).await?; + if scope { + assert!( + session + .rpc() + .connectors() + .refresh(ConnectorAccountRequest { + account_id: selected.account_id.clone(), + }) + .await + .is_err() + ); + let blocked = session.rpc().connectors().get_status().await?; + let requirement = blocked + .authorization_requirement + .ok_or("Missing scope requirement")?; + assert_eq!(requirement.account_id, selected.account_id); + assert_eq!( + requirement.scope, + ConnectorAuthorizationScope::WritePluginGatewayConnections + ); + assert_eq!( + *reasons.lock().expect("token reason lock is not poisoned"), + vec![GitHubTokenRequestReason::Initial] + ); + mode.store(1, Ordering::SeqCst); + control(Some(json!({ "credential": "rotated" }))).await?; + let recovered = reconcile(&session, &selected.account_id, Some("mail")).await?; + check_connected(&recovered); + assert!(recovered.authorization_requirement.is_none()); + assert_eq!( + recovered.account_id.as_deref(), + Some(selected.account_id.as_str()) + ); + assert_eq!( + control(None).await?["generations"], + json!(["first", "rotated"]) + ); + assert_eq!( + *reasons.lock().expect("token reason lock is not poisoned"), + vec![ + GitHubTokenRequestReason::Initial, + GitHubTokenRequestReason::Refresh + ] + ); + } else { + let status = reconcile(&session, &selected.account_id, None).await?; + check_connected(&status); + let mail = server_id(&status, "mail"); + let before = control(None).await?; + mode.store(1, Ordering::SeqCst); + control(Some(json!({ "credential": "rotated" }))).await?; + let tools = session + .rpc() + .mcp() + .list_tools(McpListToolsRequest { + server_name: mail.clone(), + }) + .await?; + assert!(tools.tools.iter().any(|tool| tool.name == "remote_ping")); + assert_eq!(account(&session).await?.account_id, selected.account_id); + let after = control(None).await?; + assert_eq!( + after["mail"]["initializations"], + before["mail"]["initializations"] + ); + assert!( + after["mail"]["unauthorized"] + .as_u64() + .expect("mail unauthorized count is a number") + > 0 + ); + assert_eq!( + *reasons.lock().expect("token reason lock is not poisoned"), + vec![ + GitHubTokenRequestReason::Initial, + GitHubTokenRequestReason::Refresh + ] + ); + mode.store(2, Ordering::SeqCst); + control(Some(json!({ "credential": "other" }))).await?; + let successful_lists = after["mail"]["lists"].clone(); + assert!( + session + .rpc() + .mcp() + .list_tools(McpListToolsRequest { server_name: mail }) + .await + .is_err() + ); + assert_eq!(control(None).await?["mail"]["lists"], successful_lists); + assert_eq!(account(&session).await?.auth_info.login, "octocat"); + } + assert_eq!(control(None).await?["writes"], json!(0)); + assert_eq!( + serde_json::to_value(client.rpc().account().get_all_users().await?)?, + accounts_before + ); + session.disconnect().await?; + client.stop().await?; + assert!( + persistent_config("provider")? == config_before, + "Session callback changed persistent account configuration" + ); + Ok(()) +} + +async fn targeted() -> Result<()> { + let first_client = client("first-process", true).await?; + let second_client = client("second-process", false).await?; + let first = first_client + .create_session(config(true).with_github_token(FIRST)) + .await?; + let second = second_client + .create_session(config(true).with_github_token(FIRST)) + .await?; + let first_account = account(&first).await?; + let second_account = account(&second).await?; + reconcile(&first, &first_account.account_id, None).await?; + reconcile(&second, &second_account.account_id, None).await?; + let outcome = first + .rpc() + .connectors() + .reconnect(ConnectorConnectRequest { + account_id: first_account.account_id, + connector_name: "mail".to_owned(), + }) + .await?; + assert!(matches!(outcome, ConnectorConnectResult::Connected(_))); + assert_eq!(control(None).await?["writes"], json!(1)); + let before = control(None).await?; + let status = reconcile(&second, &second_account.account_id, Some("mail")).await?; + check_connected(&status); + let after = control(None).await?; + assert_eq!( + after["mail"]["initializations"] + .as_u64() + .expect("mail initializations count is a number"), + before["mail"]["initializations"] + .as_u64() + .expect("mail initializations count is a number") + + 1 + ); + assert_eq!( + after["calendar"]["initializations"], + before["calendar"]["initializations"] + ); + assert_eq!( + after["ordinary"]["initializations"], + before["ordinary"]["initializations"] + ); + assert_eq!(after["writes"], json!(1)); + second + .rpc() + .mcp() + .disable(McpDisableRequest { + server_name: server_id(&status, "calendar"), + }) + .await?; + let status = reconcile(&second, &second_account.account_id, Some("mail")).await?; + assert!( + status + .runtime_servers + .iter() + .any(|server| server.connector_name == "calendar" + && server.status == ConnectorMcpStatus::Disabled) + ); + let final_state = control(None).await?; + assert_eq!( + final_state["calendar"]["initializations"], + before["calendar"]["initializations"] + ); + assert_eq!( + final_state["ordinary"]["initializations"], + before["ordinary"]["initializations"] + ); + assert_eq!(final_state["writes"], json!(1)); + first.disconnect().await?; + second.disconnect().await?; + first_client.stop().await?; + second_client.stop().await?; + Ok(()) +} + +async fn disabled() -> Result<()> { + let client = client("disabled", false).await?; + let session = client + .create_session(config(false).with_github_token(FIRST)) + .await?; + let capabilities = session.rpc().connectors().get_capabilities().await?; + assert_eq!(capabilities.availability, ConnectorAvailability::Disabled); + assert_eq!(capabilities.session_account_selection, Some(true)); + assert_eq!(capabilities.targeted_reconcile, Some(true)); + let before = control(None).await?; + assert!(session.rpc().connectors().get_account().await?.is_none()); + reconcile(&session, "not-resolved", Some("mail")).await?; + let after = control(None).await?; + for key in ["reads", "writes", "mail", "calendar"] { + assert_eq!(after[key], before[key]); + } + session.disconnect().await?; + client.stop().await?; + Ok(()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let case = setting("CONNECTOR_LOCAL_CASE"); + let work = async { + match case.as_str() { + "static" => static_identity().await, + "rotation" => provider_case(false).await, + "scope" => provider_case(true).await, + "targeted" => targeted().await, + "disabled" => disabled().await, + _ => Err("Unknown local integration case".into()), + } + }; + tokio::time::timeout(Duration::from_secs(180), work).await??; + println!( + "PASS Rust SDK {} {}", + setting("CONNECTOR_LOCAL_TRANSPORT"), + case + ); + Ok(()) +} diff --git a/rust/tests/fixtures/connector-runtime/vitest.local.config.ts b/rust/tests/fixtures/connector-runtime/vitest.local.config.ts new file mode 100644 index 0000000000..26f85647e6 --- /dev/null +++ b/rust/tests/fixtures/connector-runtime/vitest.local.config.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// Local-only by design: this matrix is not referenced by any repository vitest +// config or workflow. + +import { fileURLToPath } from "node:url"; + +import { matrixTimeoutMs } from "./budget.ts"; + +export default { + root: fileURLToPath(new URL("../../../../", import.meta.url)), + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), + test: { + include: ["rust/tests/fixtures/connector-runtime/fixture.test.ts"], + pool: "forks", + testTimeout: matrixTimeoutMs, + }, +}; diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 73e0b7cd4d..cc6ff4d76a 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -18,11 +18,12 @@ use github_copilot_sdk::handler::{ PermissionHandler, PermissionResult, UserInputHandler, UserInputResponse, }; use github_copilot_sdk::rpc::{ - CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult, - ConnectorAccountRequest, ConnectorAvailability, ConnectorCapabilities, ConnectorCatalogResult, - ConnectorCatalogStatus, ConnectorConnectRequest, ConnectorConnectResult, - ConnectorContinueRequest, ConnectorDisconnectResult, ConnectorMcpStatus, - ConnectorReconcileRequest, ConnectorStatus, ModelSetAllowedModelsRequest, OpenCanvasInstance, + AuthInfoType, CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, + CanvasProviderOpenResult, ConnectorAccountRequest, ConnectorAvailability, + ConnectorCapabilities, ConnectorCatalogResult, ConnectorCatalogStatus, ConnectorConnectRequest, + ConnectorConnectResult, ConnectorContinueRequest, ConnectorDisconnectResult, + ConnectorMcpStatus, ConnectorReconcileOptions, ConnectorReconcileRequest, + ConnectorSessionAccount, ConnectorStatus, ModelSetAllowedModelsRequest, OpenCanvasInstance, SendAgentMode, SendMode, SendRequest, SessionRpcConnectors, }; use github_copilot_sdk::session_events::{ @@ -6655,7 +6656,9 @@ async fn rpc_namespace_session_connectors_dispatches_all_methods() { "maxDeadlineMs": 60_000, "maxPollAttempts": 10, "maxPollIntervalMs": 5_000, - "opaqueAccountSelection": true + "opaqueAccountSelection": true, + "sessionAccountSelection": true, + "targetedReconcile": true }); let server_handle = tokio::spawn(async move { @@ -6665,6 +6668,19 @@ async fn rpc_namespace_session_connectors_dispatches_all_methods() { serde_json::json!({ "sessionId": session_id }), capabilities, ), + ( + "session.connectors.getAccount", + serde_json::json!({ "sessionId": session_id }), + serde_json::json!({ + "accountId": "account-1", + "authInfo": { "type": "token", "host": "github.com", "login": "alice" }, + }), + ), + ( + "session.connectors.getAccount", + serde_json::json!({ "sessionId": session_id }), + serde_json::Value::Null, + ), ( "session.connectors.getStatus", serde_json::json!({ "sessionId": session_id }), @@ -6744,6 +6760,15 @@ async fn rpc_namespace_session_connectors_dispatches_all_methods() { "refreshCatalog": true, "sessionId": session_id }), + status.clone(), + ), + ( + "session.connectors.reconcile", + serde_json::json!({ + "accountId": "account-1", + "forceConnectorName": "github", + "sessionId": session_id + }), status, ), ]; @@ -6761,6 +6786,16 @@ async fn rpc_namespace_session_connectors_dispatches_all_methods() { let capabilities: ConnectorCapabilities = connectors.get_capabilities().await.unwrap(); assert_eq!(capabilities.availability, ConnectorAvailability::Enabled); assert_eq!(capabilities.max_poll_attempts, 10); + assert_eq!(capabilities.session_account_selection, Some(true)); + assert_eq!(capabilities.targeted_reconcile, Some(true)); + + let account: Option = connectors.get_account().await.unwrap(); + let account = account.unwrap(); + assert_eq!(account.account_id, "account-1"); + assert_eq!(account.auth_info.r#type, AuthInfoType::Token); + assert_eq!(account.auth_info.host, "github.com"); + assert_eq!(account.auth_info.login, "alice"); + assert!(connectors.get_account().await.unwrap().is_none()); let status: ConnectorStatus = connectors.get_status().await.unwrap(); assert_eq!(status.account_id.as_deref(), Some("account-1")); @@ -6847,6 +6882,14 @@ async fn rpc_namespace_session_connectors_dispatches_all_methods() { .unwrap(); assert_eq!(reconciled.catalog.unwrap().revision, 4); + let targeted = connectors + .reconcile_with_options( + ConnectorReconcileOptions::new("account-1").force_connector_name("github"), + ) + .await + .unwrap(); + assert_eq!(targeted.catalog.unwrap().revision, 4); + timeout(TIMEOUT, server_handle).await.unwrap().unwrap(); } diff --git a/scripts/ci/codegen-entrypoints.test.mjs b/scripts/ci/codegen-entrypoints.test.mjs new file mode 100644 index 0000000000..b1c522cb2b --- /dev/null +++ b/scripts/ci/codegen-entrypoints.test.mjs @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const codegenRoot = fileURLToPath(new URL("../codegen/", import.meta.url)); +const loader = createRequire(new URL("../codegen/package.json", import.meta.url)).resolve("tsx"); + +for (const language of ["python", "go", "csharp"]) { + test(`runs the ${language} generator through a Bazel-style symlink`, (t) => { + const directory = mkdtempSync(join(tmpdir(), "codegen-entrypoint-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const entry = join(directory, `${language}.ts`); + symlinkSync(join(codegenRoot, `${language}.ts`), entry); + const missingSchema = join(directory, "missing-schema.json"); + // Supplying both schema paths prevents standalone generators from acquiring a published runtime. + const result = spawnSync( + process.execPath, + ["--import", pathToFileURL(loader).href, entry, missingSchema, missingSchema], + { + cwd: directory, + encoding: "utf8", + windowsHide: true, + timeout: process.platform === "win32" ? 60_000 : 30_000, + env: { ...process.env, COPILOT_CODEGEN_OUTPUT_ROOT: join(directory, "output") }, + }, + ); + const diagnostics = `${result.stdout}\n${result.stderr}`; + assert.ifError(result.error); + assert.notEqual(result.status, 0, `Expected ${language} to reject the missing local schemas.\n${diagnostics}`); + assert.match(result.stderr, /missing-schema\.json/, diagnostics); + }); +} diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 63afde9d3d..93d785763f 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -8,6 +8,7 @@ import { execFile } from "child_process"; import fs from "fs/promises"; +import { realpathSync } from "node:fs"; import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; @@ -3015,7 +3016,7 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(__filename)) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 4f148a129f..0ef18a81d8 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -8,6 +8,7 @@ import { execFile } from "child_process"; import fs from "fs/promises"; +import { realpathSync } from "node:fs"; import type { JSONSchema7 } from "json-schema"; import path from "path"; import { fileURLToPath } from "url"; @@ -3013,8 +3014,54 @@ function emitGoAlias(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): ctx.structs.push(lines.join("\n")); } +/** + * A named definition is wire-nullable when its `anyOf` pairs a single `$ref` + * with a real `type: "null"` branch. The `{ "not": {} }` omission sentinel is + * deliberately excluded: it means "absent", not "null on the wire". Returns + * the referenced definition name, or undefined when the shape does not match. + */ +function goWireNullableRefName(schema: JSONSchema7): string | undefined { + if (!Array.isArray(schema.anyOf)) return undefined; + const branches = schema.anyOf.filter((branch): branch is JSONSchema7 => typeof branch === "object" && branch !== null); + if (branches.length !== schema.anyOf.length) return undefined; + if (!branches.some((branch) => branch.type === "null")) return undefined; + const refBranches = branches.filter((branch) => typeof branch.$ref === "string"); + if (refBranches.length !== 1 || branches.length !== 2) return undefined; + return refBranches[0].$ref!.split("/").pop(); +} + +/** + * Emits a nullable result definition as a pointer alias so the exported name + * matches what the corresponding method already returns and a JSON `null` + * cannot decode into a zero value. + */ +function emitGoNullableRefAlias(typeName: string, refName: string, schema: JSONSchema7, ctx: GoCodegenCtx): void { + if (ctx.generatedNames.has(typeName)) return; + ctx.generatedNames.add(typeName); + + const lines: string[] = []; + if (schema.description) { + pushGoCommentForContext(lines, schema.description, ctx); + } + if (isSchemaExperimental(schema)) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } + if (isSchemaDeprecated(schema)) { + pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); + } + lines.push(`type ${typeName} = *${goDefinitionName(refName)}`); + ctx.structs.push(lines.join("\n")); +} + function emitGoRpcDefinition(definitionName: string, schema: JSONSchema7, ctx: GoCodegenCtx): string { const typeName = goDefinitionName(definitionName); + + const wireNullableRef = goWireNullableRefName(schema); + if (wireNullableRef) { + emitGoNullableRefAlias(typeName, wireNullableRef, schema, ctx); + return typeName; + } + const effectiveSchema = resolveObjectSchema(schema, ctx.definitions) ?? resolveSchema(schema, ctx.definitions) ?? schema; if (isStringEnumDefinition(effectiveSchema)) { @@ -4298,11 +4345,11 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc lines.push(`\t}`); lines.push(`\treturn result, nil`); } else { - lines.push(`\tvar result ${resultType}`); + lines.push(`\tvar result ${nullableInner ? "*" : ""}${resultType}`); lines.push(`\tif err := json.Unmarshal(raw, &result); err != nil {`); lines.push(`\t\treturn nil, err`); lines.push(`\t}`); - lines.push(`\treturn &result, nil`); + lines.push(`\treturn ${nullableInner ? "" : "&"}result, nil`); } lines.push(`}`); lines.push(``); @@ -4588,7 +4635,7 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(__filename)) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index af35f314ae..81f6328c01 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -7,6 +7,7 @@ */ import fs from "fs/promises"; +import { realpathSync } from "node:fs"; import path from "path"; import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import { fileURLToPath } from "url"; @@ -290,6 +291,30 @@ function preservePythonSessionEventConstructorOrder(schema: JSONSchema7): void { } } +/** + * Optional RPC fields added after a type was first published. Python dataclasses expose + * their field order as a positional constructor contract, so these must stay last instead + * of being sorted in among the pre-existing optional fields. + */ +const PY_RPC_APPEND_LAST_FIELDS: ReadonlyArray = [ + ["ConnectorReconcileRequest", "forceConnectorName"], +]; + +/** + * Append-last entries whose property exists in this schema. An older or narrower schema + * selected for generation may predate the field, which is not an error; a field the + * schema declares but the generated dataclass lacks still fails generation. + */ +export function pythonAppendLastFieldsPresentIn( + definitions: Record, + fields: ReadonlyArray = PY_RPC_APPEND_LAST_FIELDS +): ReadonlyArray { + return fields.filter(([className, propertyName]) => { + const definition = definitions[className]; + return typeof definition === "object" && Object.hasOwn(definition.properties ?? {}, propertyName); + }); +} + function preservePythonRpcStringDateFields(definitions: Record): void { const quotaSnapshot = definitions.AccountQuotaSnapshot; const resetDate = quotaSnapshot?.properties?.resetDate as JSONSchema7 | undefined; @@ -1521,6 +1546,96 @@ function makePythonDataclassFieldKeywordOnly( return updated; } +/** + * Move optional fields listed in {@link PY_RPC_APPEND_LAST_FIELDS} to the end of their + * dataclass so a field added after publication cannot shift the positional constructor + * contract of the fields that were already there. + */ +export function appendLastPythonRpcConstructorFields( + code: string, + fields: ReadonlyArray = PY_RPC_APPEND_LAST_FIELDS +): string { + const fieldRe = /^ (\w+): .* = .*$/; + const methodRe = /^ (?:@(?:staticmethod|classmethod|property)|(?:async\s+)?def\s+)/; + + let updated = code; + for (const [className, propertyName] of fields) { + const targetField = toSnakeCase(propertyName); + const classBlockRe = new RegExp( + `(@dataclass\\r?\\nclass\\s+${escapeRegExp(className)}:[\\s\\S]*?)(?=^@dataclass|^class\\s+\\w|^def\\s+\\w|(?![\\s\\S]))`, + "m" + ); + let foundClass = false; + updated = updated.replace(classBlockRe, (block: string) => { + foundClass = true; + const lines = block.split("\n"); + const memberStart = lines.findIndex((line, index) => index >= 2 && methodRe.test(line)); + if (memberStart < 0) { + throw new Error(`Missing from_dict constructor for ${className}`); + } + + const groups: string[][] = []; + const preamble: string[] = []; + let current: string[] | undefined; + for (const line of lines.slice(2, memberStart)) { + if (/^ \w+:/.test(line)) { + current = [line]; + groups.push(current); + } else if (current) { + current.push(line); + } else { + preamble.push(line); + } + } + + const targetIndex = groups.findIndex((group) => fieldRe.exec(group[0])?.[1] === targetField); + if (targetIndex < 0) { + throw new Error(`Missing dataclass field ${className}.${targetField}`); + } + if (targetIndex === groups.length - 1) return block; + + const reordered = [ + ...groups.slice(0, targetIndex), + ...groups.slice(targetIndex + 1), + groups[targetIndex], + ].map((group) => { + const trimmed = [...group]; + while (trimmed.length > 1 && trimmed[trimmed.length - 1].trim() === "") trimmed.pop(); + return trimmed; + }); + const fieldOrder = reordered + .map((group) => group[0].match(/^ (\w+):/)?.[1]) + .filter((name): name is string => Boolean(name)); + let foundConstructor = false; + const members = lines + .slice(memberStart) + .join("\n") + .replace( + new RegExp(`return ${escapeRegExp(className)}\\(([^()\\n]*)\\)`), + (_call: string, args: string) => { + const parsed = args.split(",").map((arg) => arg.trim()); + if (parsed.length !== fieldOrder.length || !parsed.every((arg) => fieldOrder.includes(arg))) { + throw new Error(`Unexpected from_dict constructor arguments for ${className}`); + } + foundConstructor = true; + return `return ${className}(${fieldOrder.join(", ")})`; + } + ); + // Reordering fields without rewriting the positional constructor call would + // silently bind every later argument to the wrong field. + if (!foundConstructor) { + throw new Error(`Missing from_dict constructor for ${className}`); + } + + return [...lines.slice(0, 2), ...preamble, ...reordered.flat(), "", members].join("\n"); + }); + if (!foundClass) { + throw new Error(`Missing dataclass ${className}`); + } + } + return updated; +} + function reorderPythonDataclassFields(code: string): string { const fieldRe = /^ \w+: (?:Any|bool|int|float|str|dict|list|ClassVar|[A-Z_]\w*|['"][A-Z_]\w*)(?:[^=]*)?(?: = .*)?$/; @@ -1627,8 +1742,12 @@ function getMethodResultSchema(method: RpcMethod): JSONSchema7 | undefined { return resolveSchema(method.result, rpcDefinitions) ?? method.result ?? undefined; } -function isPythonObjectResultSchema(schema: JSONSchema7 | undefined): boolean { +export function isPythonObjectResultSchema( + schema: JSONSchema7 | undefined, + definitions: DefinitionCollections = rpcDefinitions, +): boolean { if (!schema) return false; + schema = resolveSchema(schema, definitions) ?? schema; if (isObjectSchema(schema)) return true; const variants = schema.anyOf ?? schema.oneOf; @@ -1636,7 +1755,7 @@ function isPythonObjectResultSchema(schema: JSONSchema7 | undefined): boolean { const nonNullVariants = variants .filter((variant): variant is JSONSchema7 => typeof variant === "object" && variant !== null) - .map((variant) => resolveObjectSchema(variant, rpcDefinitions) ?? resolveSchema(variant, rpcDefinitions) ?? variant) + .map((variant) => resolveObjectSchema(variant, definitions) ?? resolveSchema(variant, definitions) ?? variant) .filter( (variant) => variant.type !== "null" && @@ -1648,7 +1767,7 @@ function isPythonObjectResultSchema(schema: JSONSchema7 | undefined): boolean { ); if (nonNullVariants.length === 1) { - return isPythonObjectResultSchema(nonNullVariants[0]); + return isPythonObjectResultSchema(nonNullVariants[0], definitions); } return nonNullVariants.length > 1 && findPyDiscriminator(nonNullVariants) !== null; @@ -3296,6 +3415,10 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema ); typesCode = removeRequiredAnyDefaultsForPython(typesCode, allDefinitions, allDefinitionCollections); typesCode = reorderPythonDataclassFields(typesCode); + typesCode = appendLastPythonRpcConstructorFields( + typesCode, + pythonAppendLastFieldsPresentIn(allDefinitions) + ); // Fix bare except: to use Exception (required by ruff/pylint) typesCode = typesCode.replace(/except:/g, "except Exception:"); // Remove unnecessary pass when class has methods (quicktype generates pass for empty schemas) @@ -3432,12 +3555,25 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema rootFieldTypes.set(match[1], match[2]); } } + const nullableAliasTargets = new Map(); + for (const [defName, definition] of Object.entries(allDefinitions)) { + if (!definition || typeof definition !== "object" || !Array.isArray(definition.anyOf)) continue; + const branches = definition.anyOf.filter((branch): branch is JSONSchema7 => typeof branch === "object"); + // Require a real `type: "null"` branch; the `{ "not": {} }` omission sentinel is not a wire null. + if (!branches.some((branch) => branch.type === "null")) continue; + const refBranches = branches.filter((branch) => typeof branch.$ref === "string"); + if (refBranches.length !== 1) continue; + nullableAliasTargets.set(defName, refBranches[0].$ref!.split("/").pop()!); + } for (const defName of Object.keys(allDefinitions)) { const actualName = rootFieldTypes.get(toSnakeCase(defName)); if (actualName) { definitionAliases.set(defName.toLowerCase(), actualName); if (actualName !== defName && !actualTypeNames.has(defName.toLowerCase()) && /^[A-Za-z_]\w*$/.test(defName)) { - publicTypeAliases.set(defName, actualName); + publicTypeAliases.set( + defName, + nullableAliasTargets.get(defName) === actualName ? `${actualName} | None` : actualName + ); } } } @@ -4249,7 +4385,7 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(__filename)) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index a9aafa681b..31afeae395 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -2003,8 +2003,18 @@ export function generateApiTypesCode( const current = inlineMethodParamSchemas.get(name) ?? (def as JSONSchema7); const legacyRequest = legacyRequests.get(name); const schema = legacyRequest ? rustLegacyStructSchema(current, legacyRequest, name) : current; + const nullableRef = getNullableInner(schema)?.$ref; - if (schema.enum && Array.isArray(schema.enum)) { + if (nullableRef) { + recordExternalRustTypeRef(nullableRef, ctx); + const innerType = rustRefTypeName(nullableRef, defCollections); + emitRustTypeAlias( + name, + schema, + hasWireNullBranch(schema) ? `Option<${innerType}>` : innerType, + ctx, + ); + } else if (schema.enum && Array.isArray(schema.enum)) { emitRustStringEnum( name, schema.enum as string[], @@ -2375,6 +2385,23 @@ function getResultTypeName( return `${toPascalCase(method.rpcMethod)}Result`; } +/** + * A named alias is wire-nullable only when its `anyOf` carries a real + * `type: "null"` branch. `getNullableInner` also accepts the `{ "not": {} }` + * omission sentinel, which means "absent", not "may be null on the wire", so + * aliasing it to `Option` would accept and serialize a null the schema + * does not permit. + */ +function hasWireNullBranch(schema: JSONSchema7): boolean { + if (!Array.isArray(schema.anyOf)) return false; + return schema.anyOf.some( + (branch) => + typeof branch === "object" && + branch !== null && + (branch as JSONSchema7).type === "null", + ); +} + function methodUsesInternalSchema( schema: JSONSchema7 | null | undefined, defCollections: DefinitionCollections, diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f1a59faa1b..cd7729ce3e 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -671,13 +671,22 @@ function resultTypeName(method: RpcMethod): string { return externalRef?.definitionName ?? getRpcSchemaTypeName(schema, method.rpcMethod.split(".").map(toPascalCase).join("") + "Result"); } -function tsNullableResultTypeName(method: RpcMethod): string | undefined { - const resultSchema = getMethodResultSchema(method); +export function tsNullableResultTypeName( + method: RpcMethod, + resultSchema = getMethodResultSchema(method), +): string | undefined { if (!resultSchema) return undefined; const inner = getNullableInner(resultSchema); if (!inner) return undefined; // Resolve $ref to a type name if (inner.$ref) { + if ( + method.result?.$ref && + resultSchema.title && + resultSchema.anyOf?.some((variant) => typeof variant === "object" && variant.type === "null") + ) { + return resultSchema.title; + } const refName = inner.$ref.split("/").pop(); if (refName) return `${toPascalCase(refName)} | undefined`; } diff --git a/scripts/runtime-release.mjs b/scripts/runtime-release.mjs new file mode 100644 index 0000000000..fcd3585ca3 --- /dev/null +++ b/scripts/runtime-release.mjs @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Resolve a pinned runtime's public release, preserving the caller's mirror override. + * @param {string} version + * @param {string | null | undefined} [baseUrl] Pass null to ignore ambient mirror settings. + */ +export function runtimeReleaseUrl(version, baseUrl = process.env.COPILOT_CLI_DOWNLOAD_BASE_URL) { + const unstable = + /^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){2}(?:-unstable|-(?:0|[1-9][0-9]*)\.unstable)\.r[1-9][0-9]*\.g[0-9a-f]{7}$/.test( + version, + ); + const repository = unstable ? "copilot-sdk" : "copilot-cli"; + const tag = unstable ? `runtime-${version}` : `v${version}`; + const base = baseUrl ?? `https://github.com/github/${repository}/releases/download`; + return `${base.replace(/\/+$/, "")}/${tag}`; +} diff --git a/test/harness/certUtils.ts b/test/harness/certUtils.ts index ed1754547a..56be7d41b0 100644 --- a/test/harness/certUtils.ts +++ b/test/harness/certUtils.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +import net from "net"; import tls from "tls"; import forge from "node-forge"; @@ -35,6 +36,7 @@ export function generateCA(): CaData { cert.setExtensions([ { name: "basicConstraints", cA: true, critical: true }, { name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true }, + { name: "subjectKeyIdentifier" }, ]); cert.sign(keys.privateKey, forge.md.sha256.create()); @@ -47,10 +49,34 @@ export function generateCA(): CaData { }; } +export interface IdentityData { + certPem: string; + keyPem: string; +} + export function createSecureContextForHost( hostname: string, ca: CaData, ): tls.SecureContext { + const identity = createIdentityForHost(hostname, ca); + return tls.createSecureContext({ + key: identity.keyPem, + cert: identity.certPem, + ca: ca.certPem, + }); +} + +/** + * Issues a CA-signed server identity for `hostname`. IP literals get an IP + * subjectAltName rather than a DNS one, because verifiers reject a DNS name + * when the client connected to an address; `serverAuth` and modern key-usage + * extensions keep strict platform verifiers (macOS SecTrust) from rejecting + * the chain outright. + */ +export function createIdentityForHost( + hostname: string, + ca: CaData, +): IdentityData { const keys = forge.pki.rsa.generateKeyPair(2048); const cert = forge.pki.createCertificate(); cert.publicKey = keys.publicKey; @@ -65,17 +91,31 @@ export function createSecureContextForHost( cert.setSubject([{ name: "commonName", value: hostname }]); cert.setIssuer(ca.caCert.subject.attributes); cert.setExtensions([ + { name: "basicConstraints", cA: false, critical: true }, + { + name: "keyUsage", + digitalSignature: true, + keyEncipherment: true, + critical: true, + }, + { name: "extKeyUsage", serverAuth: true }, { name: "subjectAltName", - altNames: [{ type: 2, value: hostname }], + altNames: net.isIP(hostname) + ? [{ type: 7, ip: hostname }] + : [{ type: 2, value: hostname }], + }, + { + name: "authorityKeyIdentifier", + keyIdentifier: ca.caCert.generateSubjectKeyIdentifier().getBytes(), }, + { name: "subjectKeyIdentifier" }, ]); cert.sign(ca.caKey, forge.md.sha256.create()); - return tls.createSecureContext({ - key: forge.pki.privateKeyToPem(keys.privateKey), - cert: forge.pki.certificateToPem(cert), - ca: ca.certPem, - }); + return { + keyPem: forge.pki.privateKeyToPem(keys.privateKey), + certPem: forge.pki.certificateToPem(cert), + }; } diff --git a/test/harness/replayingCapiProxy.test.ts b/test/harness/replayingCapiProxy.test.ts index 06d5117c96..b1040f923b 100644 --- a/test/harness/replayingCapiProxy.test.ts +++ b/test/harness/replayingCapiProxy.test.ts @@ -734,6 +734,53 @@ Always include PINEAPPLE_COCONUT_42. ); }); + test("names runtime agent IDs that no task call introduced", async () => { + const runtimeAgentId = "3e0c7565-6091-58cb-85bb-6cb14db23ef7"; + const agentResult = (agentId: string) => + `Agent completed. agent_id: ${agentId}, agent_type: general-purpose, status: completed, description: Probe, elapsed: 0s, total_turns: 0, duration: 0s\n\nDone.`; + const requestBody = JSON.stringify({ + messages: [ + { role: "user", content: "Check the agent" }, + { + role: "assistant", + tool_calls: [ + { + id: "tc1", + type: "function", + function: { + name: "read_agent", + arguments: JSON.stringify({ + agent_id: runtimeAgentId, + since_turn: 0, + }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "tc1", + content: agentResult(runtimeAgentId), + }, + ], + }); + const responseBody = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "Done" } }], + }); + + const outputPath = await createProxy([ + { url: "/chat/completions", requestBody, responseBody }, + ]); + + const result = await readYamlOutput(outputPath); + const [, readCall, readResult] = result.conversations[0].messages; + expect(JSON.parse(readCall.tool_calls![0].function!.arguments!)).toEqual({ + agent_id: "api-agent", + since_turn: 0, + }); + expect(readResult.content).toBe(agentResult("api-agent")); + }); + test("normalizes GitHub CLI proxy auth failures", async () => { const requestBody = JSON.stringify({ messages: [ @@ -1660,6 +1707,94 @@ Always include PINEAPPLE_COCONUT_42. } }); + test("replays reads of an agent no task call started with its runtime ID", async () => { + const cachePath = path.join(tempDir, "cache.yaml"); + const agentResult = (agentId: string) => + `Agent completed. agent_id: ${agentId}, agent_type: general-purpose, status: completed, description: Probe, elapsed: 0s, total_turns: 0, duration: 0s\n\nDone.`; + const readCall = (id: string, agentId: string) => ({ + role: "assistant" as const, + tool_calls: [ + { + id, + type: "function" as const, + function: { + name: "read_agent", + arguments: JSON.stringify({ agent_id: agentId, since_turn: 0 }), + }, + }, + ], + }); + const cacheContent = yaml.stringify({ + models: ["test-model"], + conversations: [ + { + messages: [ + { role: "system", content: "${system}" }, + { role: "user", content: "Check the agent" }, + readCall("toolcall_0", "api-agent"), + { + role: "tool", + tool_call_id: "toolcall_0", + content: agentResult("api-agent"), + }, + { + role: "assistant", + tool_calls: [ + { + id: "toolcall_1", + type: "function", + function: { + name: "write_agent", + arguments: '{"agent_id":"api-agent","message":"Continue"}', + }, + }, + ], + }, + ], + }, + ], + } satisfies NormalizedData); + await writeFile(cachePath, cacheContent); + + const proxy = new ReplayingCapiProxy( + "http://localhost:9999", + cachePath, + workDir, + ); + const proxyUrl = await proxy.start(); + // A different ID than any recording saw, as each run generates its own. + const runtimeAgentId = "8d0a3f62-1b4e-4c9a-9f57-2e6b0c1d7a45"; + + try { + const response = await makeRequest(proxyUrl, "/chat/completions", { + body: { + model: "test-model", + messages: [ + { role: "system", content: "Be helpful" }, + { role: "user", content: "Check the agent" }, + readCall("runtime-call-id", runtimeAgentId), + { + role: "tool", + tool_call_id: "runtime-call-id", + content: agentResult(runtimeAgentId), + }, + ], + }, + }); + + expect(response.status).toBe(200); + const parsed = JSON.parse(response.body) as ChatCompletion; + const toolCall = parsed.choices[0].message + .tool_calls![0] as ChatCompletionMessageFunctionToolCall; + expect(JSON.parse(toolCall.function.arguments)).toEqual({ + agent_id: runtimeAgentId, + message: "Continue", + }); + } finally { + await proxy.stop(); + } + }); + test("matches parallel tool results regardless of arrival order", async () => { const cachePath = path.join(tempDir, "cache.yaml"); const cacheContent = yaml.stringify({ diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 15dc18a7d3..c8f3b29f43 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -1135,6 +1135,7 @@ function normalizeToolCalls( const precedingMessages: NormalizedMessage[] = []; let counter = 0; let unnamedBackgroundAgentCounter = 0; + let unnamedAgentCounter = 0; for (const msg of conv.messages) { for (const tc of msg.tool_calls ?? []) { // Normalize ID in tool calls @@ -1163,6 +1164,16 @@ function normalizeToolCalls( tc.function?.name === "read_agent" || tc.function?.name === "write_agent" ) { + for (const runtimeId of getUnnamedRuntimeAgentIds( + tc.function.name, + tc.function.arguments, + (id) => backgroundAgentNamesByRuntimeId.has(id), + )) { + backgroundAgentNamesByRuntimeId.set( + runtimeId, + unnamedAgentName(unnamedAgentCounter++), + ); + } tc.function.arguments = normalizeBackgroundAgentArguments( tc.function.arguments, backgroundAgentNamesByRuntimeId, @@ -1666,6 +1677,51 @@ function replaceBackgroundAgentIds( return normalized; } +const runtimeAgentIdPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Runtime-generated agent IDs a `read_agent` or `write_agent` call names that + * no `task` result introduced, such as the synthetic read the runtime adds when + * an SDK-started agent goes idle. `isKnown` reports IDs that already have a + * stable name. + */ +function getUnnamedRuntimeAgentIds( + toolName: string | undefined, + argumentsJson: string | undefined, + isKnown: (runtimeId: string) => boolean, +): string[] { + if ( + (toolName !== "read_agent" && toolName !== "write_agent") || + !argumentsJson + ) { + return []; + } + try { + const args = JSON.parse(argumentsJson) as { + agent_id?: unknown; + agent_ids?: unknown; + }; + const agentIds = new Set([ + args.agent_id, + ...(Array.isArray(args.agent_ids) ? args.agent_ids : []), + ]); + return [...agentIds].filter( + (agentId): agentId is string => + typeof agentId === "string" && + runtimeAgentIdPattern.test(agentId) && + !isKnown(agentId), + ); + } catch { + return []; + } +} + +/** Stable name for the Nth agent a conversation reads but did not start. */ +function unnamedAgentName(index: number): string { + return index === 0 ? "api-agent" : `api-agent-${index}`; +} + function rewriteBackgroundAgentArguments( argumentsJson: string, replacements: Map, @@ -1728,8 +1784,16 @@ function extractBackgroundAgentIds( }; const backgroundAgentNamesByToolCallId = new Map(); let unnamedBackgroundAgentCounter = 0; + let unnamedAgentCounter = 0; for (const message of request.messages ?? []) { for (const toolCall of message.tool_calls ?? []) { + for (const runtimeId of getUnnamedRuntimeAgentIds( + toolCall.function?.name, + toolCall.function?.arguments, + (id) => [...result.values()].includes(id), + )) { + result.set(unnamedAgentName(unnamedAgentCounter++), runtimeId); + } if ( toolCall.id && toolCall.function?.name === "task" && diff --git a/test/harness/test-mcp-oauth-server.mjs b/test/harness/test-mcp-oauth-server.mjs index d5872e9c00..d5d28d8438 100644 --- a/test/harness/test-mcp-oauth-server.mjs +++ b/test/harness/test-mcp-oauth-server.mjs @@ -35,6 +35,7 @@ export async function startOAuthMcpServer({ }) : Promise.resolve(); const requests = []; + let toolMode = "unchanged"; const tokens = { initial: expectedToken, refresh: `${expectedToken}-refresh`, @@ -72,6 +73,19 @@ export async function startOAuthMcpServer({ return; } + if (req.method === "POST" && url.pathname === "/__tool-mode") { + const parsedBody = parseJsonBody(body); + const mode = parsedBody.ok ? parsedBody.value?.mode : undefined; + if (!["unchanged", "removed", "changed-schema"].includes(mode)) { + respondJson(res, 400, { error: "invalid_tool_mode" }); + return; + } + toolMode = mode; + res.writeHead(204); + res.end(); + return; + } + if (req.method === "GET" && url.pathname === PROTECTED_RESOURCE_PATH) { respondJson(res, 200, { resource: `${baseUrl}/mcp`, @@ -161,9 +175,9 @@ export async function startOAuthMcpServer({ const response = Array.isArray(message) ? message - .map((item) => handleJsonRpcMessage(item)) + .map((item) => handleJsonRpcMessage(item, toolMode)) .filter((item) => item !== undefined) - : handleJsonRpcMessage(message); + : handleJsonRpcMessage(message, toolMode); if ( response === undefined || @@ -245,7 +259,7 @@ function getReplacementChallenge(message, token, tokens, baseUrl) { return undefined; } -function handleJsonRpcMessage(message) { +function handleJsonRpcMessage(message, toolMode) { if (!message || typeof message !== "object" || !("id" in message)) { return undefined; } @@ -262,6 +276,29 @@ function handleJsonRpcMessage(message) { }, }; case "tools/list": + if (toolMode === "removed") { + return { + jsonrpc: "2.0", + id: message.id, + result: { tools: [] }, + }; + } + const inputSchema = { + type: "object", + properties: { + scenario: { + type: "string", + enum: ["initial", "refresh", "upscope", "reauth", "cancel"], + }, + ...(toolMode === "changed-schema" + ? { replacementOnly: { type: "boolean" } } + : {}), + }, + ...(toolMode === "changed-schema" + ? { required: ["replacementOnly"] } + : {}), + additionalProperties: false, + }; return { jsonrpc: "2.0", id: message.id, @@ -270,16 +307,7 @@ function handleJsonRpcMessage(message) { { name: "whoami", description: "Returns the authenticated test principal.", - inputSchema: { - type: "object", - properties: { - scenario: { - type: "string", - enum: ["initial", "refresh", "upscope", "reauth", "cancel"], - }, - }, - additionalProperties: false, - }, + inputSchema, _meta: { "ui.visibility": ["model", "app"] }, }, ], diff --git a/test/snapshots/pending_work_resume/should_preserve_a_completed_siblings_result_on_cold_resume.yaml b/test/snapshots/pending_work_resume/should_preserve_a_completed_siblings_result_on_cold_resume.yaml new file mode 100644 index 0000000000..92b31e143f --- /dev/null +++ b/test/snapshots/pending_work_resume/should_preserve_a_completed_siblings_result_on_cold_resume.yaml @@ -0,0 +1,30 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Call pending_lookup_a with value 'alpha' and pending_lookup_b with value 'beta', then reply with both results. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: pending_lookup_a + arguments: '{"value":"alpha"}' + - id: toolcall_1 + type: function + function: + name: pending_lookup_b + arguments: '{"value":"beta"}' + - role: tool + tool_call_id: toolcall_0 + content: PARALLEL_A_ALPHA + - role: tool + tool_call_id: toolcall_1 + content: The execution of this tool, or a previous tool was interrupted. + - role: user + content: "Use the completed lookup result and report it without re-running pending_lookup_a. Reply with exactly: COLD_RESUMED_A_RETAINED" + - role: assistant + content: COLD_RESUMED_A_RETAINED diff --git a/test/snapshots/rpc_tasks_and_handlers/should_start_a_general_purpose_agent_on_the_parent_s_model.yaml b/test/snapshots/rpc_tasks_and_handlers/should_start_a_general_purpose_agent_on_the_parent_s_model.yaml new file mode 100644 index 0000000000..bff3a7e943 --- /dev/null +++ b/test/snapshots/rpc_tasks_and_handlers/should_start_a_general_purpose_agent_on_the_parent_s_model.yaml @@ -0,0 +1,40 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Reply with TASK_MODEL_CHILD_DONE exactly. + - role: assistant + content: TASK_MODEL_CHILD_DONE + - messages: + - role: system + content: ${system} + - role: user + content: Reply with TASK_MODEL_READY exactly. When a background agent notification arrives later, reply with + TASK_MODEL_NOTIFIED exactly. + - role: assistant + content: TASK_MODEL_READY + - role: user + content: |- + + Agent "sdk-inherited-model-agent" (general-purpose) has finished processing and is now idle. + + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: read_agent + arguments: '{"agent_id":"api-agent","since_turn":0}' + - role: tool + tool_call_id: toolcall_0 + content: >- + Agent completed. agent_id: api-agent, agent_type: general-purpose, status: completed, description: SDK + inherited model coverage, elapsed: 0s, total_turns: 0, duration: 0s + + + TASK_MODEL_CHILD_DONE + - role: assistant + content: TASK_MODEL_NOTIFIED From 9b28c9467ab91a641cbef3fe8e4c706122e8ed8c Mon Sep 17 00:00:00 2001 From: "copilot-cli-release-app[bot]" Date: Fri, 2 Oct 2026 16:15:24 +0000 Subject: [PATCH 5/5] Update SDK snapshot for Copilot CLI 1.0.92-1 --- .github/workflows/sdk-nodejs.yml | 6 +- .github/workflows/sdk-platform.yml | 9 +- .github/workflows/sdk-runtime-artifact.yml | 1 + .github/workflows/sdk.yml | 8 +- BUILD.bazel | 2 + CONTRIBUTING.md | 19 + docs/features/mcp.md | 98 +++ dotnet/src/Generated/Rpc.cs | 383 ++++++++- dotnet/src/Generated/SessionEvents.cs | 17 + dotnet/test/Unit/McpPromptsTests.cs | 48 ++ dotnet/test/Unit/RpcMcpPromptsTests.cs | 65 ++ go/rpc/mcp_prompts_test.go | 79 ++ go/rpc/zrpc.go | 272 ++++++- go/rpc/zsession_events.go | 8 + java/README.md | 5 + java/scripts/codegen/java.test.ts | 38 + java/scripts/codegen/java.ts | 6 + .../AssistantFusionPhaseCompletedEvent.java | 2 + .../AssistantFusionPhaseFailedEvent.java | 2 + .../AssistantFusionPhaseStartedEvent.java | 2 + .../generated/AssistantMessageEvent.java | 3 + .../generated/AssistantUsageEvent.java | 3 + .../generated/ModelCallFailureEvent.java | 2 + .../generated/ModelCallStartEvent.java | 2 + .../PermissionAssentDetectedEvent.java | 3 + .../PermissionCarriedForwardEvent.java | 5 + .../generated/PermissionCompletedEvent.java | 2 + ...ermissionContextualAuthorizationEvent.java | 7 + ...sionMessageAuthorizationDegradedEvent.java | 2 + .../PermissionMessageAuthorizationEvent.java | 10 + ...rmissionMessageAuthorizationReadEvent.java | 3 + .../generated/PromptCacheBreakEvent.java | 2 + .../SessionAutoModeResolvedEvent.java | 2 + .../generated/SessionFusionResolvedEvent.java | 6 + .../SessionPermissionsChangedEvent.java | 4 + .../generated/SessionShutdownEvent.java | 2 + .../generated/ToolExecutionCompleteEvent.java | 4 + .../generated/ToolExecutionProgressEvent.java | 6 +- .../generated/ToolExecutionStartEvent.java | 2 + .../copilot/generated/rpc/McpPrompt.java | 41 + .../generated/rpc/McpPromptArgument.java | 36 + .../copilot/generated/rpc/McpPromptIcon.java | 37 + .../generated/rpc/McpPromptMessage.java | 34 + .../copilot/generated/rpc/McpPromptRole.java | 35 + .../copilot/generated/rpc/SessionMcpApi.java | 3 + .../generated/rpc/SessionMcpOauthApi.java | 20 +- .../rpc/SessionMcpOauthCompleteParams.java | 34 + .../rpc/SessionMcpOauthLoginParams.java | 2 +- .../rpc/SessionMcpOauthLoginRequest.java | 26 +- .../rpc/SessionMcpOauthLoginResult.java | 6 +- .../generated/rpc/SessionMcpPromptsApi.java | 65 ++ .../rpc/SessionMcpPromptsGetParams.java | 37 + .../rpc/SessionMcpPromptsGetResult.java | 38 + .../rpc/SessionMcpPromptsListParams.java | 34 + .../rpc/SessionMcpPromptsListResult.java | 38 + .../generated/rpc/SessionShellApi.java | 2 +- .../generated/rpc/SessionShellExecParams.java | 2 +- .../generated/rpc/SessionShellExecResult.java | 4 +- .../copilot/generated/rpc/McpPromptsTest.java | 86 ++ nodejs/package.json | 2 +- nodejs/src/cliVersion.ts | 2 +- nodejs/src/generated/rpc.ts | 756 ++++++++++++------ nodejs/src/generated/session-events.ts | 10 + .../e2e/commit_trailer_resume.e2e.test.ts | 162 ++++ nodejs/test/e2e/harness/CapiProxy.test.ts | 340 ++++++++ nodejs/test/e2e/harness/CapiProxy.ts | 152 +++- nodejs/test/e2e/harness/globalSetup.ts | 38 + nodejs/test/e2e/harness/proxyBundleContext.ts | 11 + nodejs/test/e2e/harness/sdkTestHelper.ts | 7 +- nodejs/test/e2e/mcp_oauth.e2e.test.ts | 64 +- .../mcp_remote_session_closure.e2e.test.ts | 176 ++++ .../test/e2e/rpc_mcp_and_skills.e2e.test.ts | 100 +++ .../test/e2e/rpc_surface_coverage.e2e.test.ts | 2 +- nodejs/test/mcp-prompts.test.ts | 106 +++ nodejs/test/shared-codegen.test.ts | 35 +- nodejs/test/typescript-codegen.test.ts | 5 +- nodejs/vitest.config.ts | 1 + python/copilot/generated/rpc.py | 549 +++++++++++-- python/copilot/generated/session_events.py | 17 + python/test_mcp_prompts_generated.py | 72 ++ rust/src/generated/api_types.rs | 320 +++++++- rust/src/generated/rpc.rs | 111 ++- rust/src/generated/session_events.rs | 17 + rust/src/installation_confirmation.rs | 1 + rust/tests/e2e/commands.rs | 2 +- rust/tests/mcp_prompts_test.rs | 58 ++ scripts/codegen/csharp.ts | 4 +- scripts/codegen/go.ts | 4 +- scripts/codegen/python.ts | 10 +- scripts/codegen/typescript.ts | 29 +- scripts/codegen/utils.ts | 27 + test/harness/mcp-prompt-fixtures.json | 129 +++ test/harness/test-mcp-oauth-server.mjs | 24 + test/harness/test-mcp-server.mjs | 71 +- .../test-mcp-session-expiry-server.mjs | 212 +++++ ...er_an_initial_opt_out_via_cold_resume.yaml | 14 + ...an_initial_opt_out_via_options_update.yaml | 14 + ...ross_cold_resume__quoted_tags__false_.yaml | 18 + ...cross_cold_resume__quoted_tags__true_.yaml | 18 + 99 files changed, 4977 insertions(+), 433 deletions(-) create mode 100644 dotnet/test/Unit/McpPromptsTests.cs create mode 100644 dotnet/test/Unit/RpcMcpPromptsTests.cs create mode 100644 go/rpc/mcp_prompts_test.go create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPrompt.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptArgument.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptIcon.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptMessage.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptRole.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthCompleteParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsApi.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetResult.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListParams.java create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListResult.java create mode 100644 java/sdk/src/test/java/com/github/copilot/generated/rpc/McpPromptsTest.java create mode 100644 nodejs/test/e2e/commit_trailer_resume.e2e.test.ts create mode 100644 nodejs/test/e2e/harness/CapiProxy.test.ts create mode 100644 nodejs/test/e2e/harness/globalSetup.ts create mode 100644 nodejs/test/e2e/harness/proxyBundleContext.ts create mode 100644 nodejs/test/e2e/mcp_remote_session_closure.e2e.test.ts create mode 100644 nodejs/test/mcp-prompts.test.ts create mode 100644 python/test_mcp_prompts_generated.py create mode 100644 rust/tests/mcp_prompts_test.rs create mode 100644 test/harness/mcp-prompt-fixtures.json create mode 100644 test/harness/test-mcp-session-expiry-server.mjs create mode 100644 test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_cold_resume.yaml create mode 100644 test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_options_update.yaml create mode 100644 test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__false_.yaml create mode 100644 test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__true_.yaml diff --git a/.github/workflows/sdk-nodejs.yml b/.github/workflows/sdk-nodejs.yml index 8ea69933cd..ee9fa82a3a 100644 --- a/.github/workflows/sdk-nodejs.yml +++ b/.github/workflows/sdk-nodejs.yml @@ -31,7 +31,7 @@ jobs: fail-fast: false matrix: os: ${{ fromJSON(inputs.platform == 'linux-x64' && '["ubuntu-latest"]' || inputs.platform == 'darwin-arm64' && '["macos-latest"]' || inputs.platform == 'win32-x64' && '["windows-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }} - transport: [default, inprocess] + transport: ${{ fromJSON(github.event_name == 'merge_group' && '["default"]' || '["default","inprocess"]') }} runs-on: ${{ matrix.os }} defaults: run: @@ -54,6 +54,9 @@ jobs: cache: npm cache-dependency-path: ${{ inputs.sdk-home }}/nodejs/package-lock.json - run: npm ci --ignore-scripts + - name: Install generator test dependencies + run: npm ci --ignore-scripts + working-directory: ${{ inputs.sdk-home }}/scripts/codegen # TEMPORARY MERGE QUEUE REDUCTION: pull requests and main pushes retain # static checks; merge groups rerun only SDK compatibility coverage. - if: github.event_name != 'merge_group' && runner.os == 'Linux' && matrix.transport == 'default' @@ -121,6 +124,7 @@ jobs: transport: ${{ matrix.transport }} command: | npm ci --ignore-scripts + (cd "$COPILOT_SDK_ROOT/scripts/codegen" && npm ci --ignore-scripts) npm run build (cd "$COPILOT_SDK_ROOT/test/harness" && npm ci --ignore-scripts) diff --git a/.github/workflows/sdk-platform.yml b/.github/workflows/sdk-platform.yml index 2fa62c3b1e..88a27b285f 100644 --- a/.github/workflows/sdk-platform.yml +++ b/.github/workflows/sdk-platform.yml @@ -1,5 +1,5 @@ -# Runs all SDK language checks for one runtime platform, allowing each platform -# to start as soon as its runtime artifact is available. +# Runs SDK language checks for one runtime platform as soon as its artifact is +# available. Merge groups retain only the Node.js compatibility lane. name: "SDK platform" on: @@ -24,6 +24,7 @@ jobs: secrets: inherit python: + if: github.event_name != 'merge_group' uses: ./.github/workflows/sdk-python.yml with: platform: ${{ inputs.platform }} @@ -31,6 +32,7 @@ jobs: secrets: inherit go: + if: github.event_name != 'merge_group' uses: ./.github/workflows/sdk-go.yml with: platform: ${{ inputs.platform }} @@ -38,6 +40,7 @@ jobs: secrets: inherit dotnet: + if: github.event_name != 'merge_group' uses: ./.github/workflows/sdk-dotnet.yml with: platform: ${{ inputs.platform }} @@ -45,6 +48,7 @@ jobs: secrets: inherit rust: + if: github.event_name != 'merge_group' uses: ./.github/workflows/sdk-rust.yml with: platform: ${{ inputs.platform }} @@ -52,6 +56,7 @@ jobs: secrets: inherit java: + if: github.event_name != 'merge_group' uses: ./.github/workflows/sdk-java.yml with: platform: ${{ inputs.platform }} diff --git a/.github/workflows/sdk-runtime-artifact.yml b/.github/workflows/sdk-runtime-artifact.yml index e1fa0cb929..08d8d0e3dd 100644 --- a/.github/workflows/sdk-runtime-artifact.yml +++ b/.github/workflows/sdk-runtime-artifact.yml @@ -3,6 +3,7 @@ name: "SDK runtime artifact" env: COPILOT_AUTO_UPDATE: "false" COPILOT_BAZEL_ANG_ENABLED: ${{ vars.COPILOT_BAZEL_ANG_ENABLED }} + COPILOT_BAZEL_ANG_SCOPE: ${{ vars.COPILOT_BAZEL_ANG_SCOPE }} COPILOT_RUNTIME_E2E_TEST_HOOKS: "1" CARGO_PROFILE_DEV_DEBUG: line-tables-only GIT_HTTP_LOW_SPEED_LIMIT: 1000 diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index b4ceb876a4..aa6d3e7759 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -4,6 +4,7 @@ name: "SDK build and test" env: COPILOT_BAZEL_ANG_ENABLED: ${{ vars.COPILOT_BAZEL_ANG_ENABLED }} + COPILOT_BAZEL_ANG_SCOPE: ${{ vars.COPILOT_BAZEL_ANG_SCOPE }} HUSKY: 0 POWERSHELL_UPDATECHECK: Off SETUP_NODE_TIMEOUT_MINUTES: 10 @@ -49,8 +50,9 @@ jobs: fi # TEMPORARY MERGE QUEUE REDUCTION: pull requests and main pushes retain the - # full platform matrix. Merge groups rerun Linux x64 as the representative - # compatibility lane. Remove the merge_group gates below to restore it. + # full SDK matrix. Merge groups rerun only Linux x64 Node.js out-of-process + # CAPI coverage. Remove the merge_group gates here and in sdk-platform.yml + # and sdk-nodejs.yml to restore the full matrix. build-runtime-windows-x64-addons: name: "Build runtime addons (win32-x64)" needs: detect-layout @@ -454,7 +456,7 @@ jobs: exit 1 fi done - echo "Linux x64 SDK compatibility and generated clients passed" + echo "Linux x64 Node.js SDK compatibility and generated clients passed" exit 0 fi diff --git a/BUILD.bazel b/BUILD.bazel index 792fd2f519..e25b8ef8e3 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,6 +1,7 @@ SHARED_CODEGEN_INPUTS = [ "nodejs/scripts/releaseArtifacts.ts", "nodejs/src/cliVersion.ts", + "scripts/codegen/legacy-parameters.ts", "scripts/codegen/package-lock.json", "scripts/codegen/package.json", "scripts/codegen/utils.ts", @@ -222,6 +223,7 @@ genrule( "java/scripts/codegen/package-lock.json", "java/scripts/codegen/package.json", "nodejs/package.json", + "scripts/codegen/legacy-parameters.ts", "scripts/runtime-layout.mjs", "scripts/runtime-release.mjs", ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad9514a939..e639b111a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,6 +116,15 @@ npm --prefix nodejs ci --ignore-scripts npm --prefix test/harness ci --ignore-scripts ``` +Node tests, including the unit-only profile, run generator subprocesses and +require the codegen package's own dependencies. Prepare these before invoking +Node tests directly or from a standalone SDK checkout (runtime build/test tasks +already prepare them): + +```bash +npm --prefix scripts/codegen ci --ignore-scripts +``` + The full Node test task also runs the corrections-script tests: ```bash @@ -319,6 +328,16 @@ For TypeScript SDK E2Es, use the existing `nodejs/test/e2e/harness/sdkTestContext.ts` fixture. In the runtime repository, also follow the `e2e-test-author` skill's SDK section. +The Node.js Vitest global setup bundles the shared replay proxy once per run +into Vitest's project-owned temporary directory (and refreshes it on watch +reruns). Each E2E context launches that bundle directly with the test runner's +Node executable, without an npm, shell, or TypeScript-loader subprocess. +Focused Vitest commands use the same setup; no separate proxy build is needed. +If startup or shutdown cleanup cannot confirm that the child exited, `CapiProxy` +retains the child handle so callers can retry cleanup with `stop()`. +Once shutdown is acknowledged, it waits for the child to finish flushing captures +and exit rather than applying a forced-termination timeout to the flush. + Owned-stdio shutdown regressions share `test/harness/stdio-shutdown-runtime.cjs` across all six SDKs. Launch it with Node and arguments ` `. The fixture acknowledges diff --git a/docs/features/mcp.md b/docs/features/mcp.md index 19f12e285b..cf3003ce8c 100644 --- a/docs/features/mcp.md +++ b/docs/features/mcp.md @@ -183,6 +183,57 @@ On session creation and a **cold** resume, disabled servers are not started and the runtime does not initiate their authentication. A resident resume cannot undo a server that the runtime has already spawned. Names are matched exactly. +## Listing and retrieving prompts + +The experimental generated MCP namespace exposes the wire JSON-RPC methods +`session.mcp.prompts.list` and `session.mcp.prompts.get`. Use the language-specific +SDK accessors below to call them. These methods target one connected server in the +current session; they do not combine results from multiple servers. + +| SDK | List prompts | Get a prompt | +| --- | --- | --- | +| Node.js | `session.rpc.mcp.prompts.list(...)` | `session.rpc.mcp.prompts.get(...)` | +| Python | `session.rpc.mcp.prompts.list(...)` | `session.rpc.mcp.prompts.get(...)` | +| Go | `session.RPC.MCP.Prompts().List(...)` | `session.RPC.MCP.Prompts().Get(...)` | +| .NET | `session.Rpc.Mcp.Prompts.ListAsync(...)` | `session.Rpc.Mcp.Prompts.GetAsync(...)` | +| Java | `session.getRpc().mcp.prompts.list(...)` | `session.getRpc().mcp.prompts.get(...)` | +| Rust | `session.rpc().mcp().prompts().list(...)` | `session.rpc().mcp().prompts().get(...)` | + +Listing requires `serverName` and accepts an optional opaque `cursor`. Each +call returns one page of typed prompt definitions and an optional `nextCursor`. +Pass that cursor in another list call to request the next page. Definitions +include names, optional titles and descriptions, and argument definitions. +An omitted argument `required` flag remains distinct from `false`. + +Getting a prompt requires `serverName` and `promptName`, with an optional +`arguments` dictionary whose values are strings. Omitting `arguments` leaves the +MCP request's arguments absent; passing `{}` sends an explicitly empty dictionary. +The runtime preserves this distinction for the server. The result contains an optional +description and ordered messages with typed roles and opaque JSON `content`. +Inspect each content block's `type` before interpreting it; content is not +flattened into text. Nested resources, annotations, metadata, unfamiliar content +types, and additional content fields retain their JSON structure. Server +extensions on typed descriptors, messages, and result envelopes are available under +`additionalProperties`. A server extension itself named `additionalProperties` +is preserved as an entry inside that map, not merged into it. +The protocol's top-level `resultType: "complete"` discriminator is consumed by +the runtime; it is not returned as an SDK field or a server extension. +Requests fail explicitly if the runtime cannot preserve the raw response, +including stdio response frames exceeding the 1 MiB capture limit. + +The returned prompt messages are not automatically sent to the model or used to +execute tools or fetch referenced resources. Your application decides how to +use the result. +While generating that result, an MCP server can request sampling or elicitation +through multi round-trip continuations. These requests use the same configured +host responders as other MCP operations; prompt retrieval does not grant +additional permission or bypass the host's decision. A missing responder or +responder error fails the request, and an elicitation decline is returned to the +server unchanged. State-only continuations do not invoke either responder. + +After an existing `mcp.prompts.list_changed` session event, list the named +server's prompts again to refresh your application's view. + ## Tool configuration You can control which tools are available to an MCP server using the `tools` field. @@ -295,6 +346,53 @@ directories for different applications. | `tools` | `string[]` | No | Tools to enable | | `timeout` | `number` | No | Timeout in milliseconds | +## OAuth ownership for remote servers + +Choose one OAuth ownership model for each authentication attempt: + +1. **Runtime-managed loopback**: Call `session.mcp.oauth.login` without a + redirect URI. The runtime opens a local callback listener and owns discovery, + PKCE, state validation, token exchange, persistence, refresh, and reconnect. +1. **Runtime-managed hosted callback**: Call `session.mcp.oauth.login` with a + trusted public HTTPS redirect URI. Your host receives the callback, but the + runtime continues to own the OAuth protocol and credentials. +1. **Host-managed OAuth**: Use the SDK's MCP authentication request handler and + return a host-acquired access token. Your host owns authorization and refresh. + +Do not register a host-managed OAuth handler for an authentication attempt that +uses a runtime-managed hosted callback. Wait until the remote server enters the +`needs-auth` state, then call the generated `session.rpc.mcp.oauth.login` method +directly with the server name and callback URI: + +```jsonc +{ + "serverName": "remote-mcp", + "redirectUri": "https://agent.example.com/oauth/callback" +} +``` + +The selected static, CIMD, or dynamically registered OAuth client must advertise +that exact URI. The URI must use HTTPS and must not contain a query or fragment. +When browser interaction is required, the result contains `authorizationUrl` +and `authorizationId`. Open `authorizationUrl` in the user's browser and retain +`authorizationId` with the target session. + +After the authorization server redirects to your endpoint, call the generated +`session.rpc.mcp.oauth.complete` method: + +```jsonc +{ + "authorizationId": "", + "callbackUrl": "https://agent.example.com/oauth/callback?code=...&state=..." +} +``` + +Construct `callbackUrl` from the configured public redirect origin and path plus +the callback's original query. Do not pass an internal service URL or trust +client-supplied `Forwarded` or `X-Forwarded-*` headers. The runtime validates the +origin, port, path, state, and OAuth response before accepting delivery. Token +exchange, persistence, and MCP reconnect then continue asynchronously. + ## Session-scoped MCP diagnostics Set `diagnostics: { sources: { mcp: { level: "debug" } } }` when you create or diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index dc5e45849e..b902333439 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -15257,7 +15257,11 @@ internal sealed class SessionMcpOauthPrepareLoginRequest [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class McpOauthLoginResult { - /// URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. When present, the runtime starts the callback listener before returning and continues the flow in the background; completion is signaled via session.mcp_server_status_changed. + /// Opaque authorization identifier returned only for a host-managed redirect URI. The runtime also sends it as the OAuth state value, so the callback endpoint can read state and pass it with the full callback URL to session.mcp.oauth.complete. + [JsonPropertyName("authorizationId")] + public string? AuthorizationId { get; set; } + + /// URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. For the default loopback flow, the runtime starts its listener before returning. With redirectUri, the host receives the callback and completes it through session.mcp.oauth.complete. The runtime continues the flow in the background and signals completion via session.mcp_server_status_changed. [Url] [StringSyntax(StringSyntaxAttribute.Uri)] [JsonPropertyName("authorizationUrl")] @@ -15272,7 +15276,7 @@ public sealed class McpOauthLoginResult public McpOwnedOauthLoginStatus? Status { get; set; } } -/// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. +/// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class McpOauthLoginRequest { @@ -15315,6 +15319,12 @@ public sealed class McpOauthLoginRequest [JsonPropertyName("publicClient")] public bool? PublicClient { get; set; } + /// Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not contain query parameters or a fragment and must be registered for the selected CIMD, DCR, or static OAuth client. + [Url] + [StringSyntax(StringSyntaxAttribute.Uri)] + [JsonPropertyName("redirectUri")] + public string? RedirectUri { get; set; } + /// Name of the remote MCP server to authenticate. [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Safe for generated string properties: JSON Schema minLength/maxLength map to string length validation, not reflection over trimmed Count members")] @@ -15323,7 +15333,7 @@ public sealed class McpOauthLoginRequest public required string ServerName { get; set; } } -/// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. +/// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] internal sealed class McpOauthLoginRequestWithSession { @@ -15366,6 +15376,12 @@ internal sealed class McpOauthLoginRequestWithSession [JsonPropertyName("publicClient")] public bool? PublicClient { get; set; } + /// Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not contain query parameters or a fragment and must be registered for the selected CIMD, DCR, or static OAuth client. + [Url] + [StringSyntax(StringSyntaxAttribute.Uri)] + [JsonPropertyName("redirectUri")] + public string? RedirectUri { get; set; } + /// Name of the remote MCP server to authenticate. [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Safe for generated string properties: JSON Schema minLength/maxLength map to string length validation, not reflection over trimmed Count members")] @@ -15378,6 +15394,27 @@ internal sealed class McpOauthLoginRequestWithSession public string SessionId { get; set; } = string.Empty; } +/// Host-delivered callback for a runtime-managed MCP OAuth login. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class McpOauthCompleteRequest +{ + /// Opaque identifier returned by session.mcp.oauth.login for the pending external callback. + [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)] + [JsonPropertyName("authorizationId")] + public string AuthorizationId { get; set; } = string.Empty; + + /// Full externally visible HTTPS callback URL received by the host, including the authorization response query parameters. Applications behind a reverse proxy must reconstruct the public URL rather than passing an internal proxy URL. + [Url] + [StringSyntax(StringSyntaxAttribute.Uri)] + [JsonPropertyName("callbackUrl")] + public string CallbackUrl { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Passive MCP OAuth probe result. `authenticated` means the server accepted the probe request while an OAuth-origin access token was attached; it does not prove the server required or independently validated that token. The probe does not make a second unauthenticated request. Failed is an expected probe-domain outcome; JSON-RPC errors are reserved for API-call failures. /// Polymorphic base type discriminated by status. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] @@ -16162,6 +16199,198 @@ internal sealed class McpResourcesListTemplatesRequest public string SessionId { get; set; } = string.Empty; } +/// An argument accepted by an MCP prompt. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class McpPromptArgument +{ + /// Argument-level metadata. + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } + + /// Server-provided non-standard argument fields. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Description of the argument. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Name of the argument. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Whether the argument is required; omission is distinct from false. + [JsonPropertyName("required")] + public bool? Required { get; set; } +} + +/// An MCP prompt icon with standard size hints and preserved non-standard fields. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class McpPromptIcon +{ + /// Server-provided non-standard icon fields. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Icon MIME type, when known. + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + /// Icon sizes, such as `48x48` or `any`. + [JsonPropertyName("sizes")] + public IList? Sizes { get; set; } + + /// Icon URI. + [JsonPropertyName("src")] + public string Src { get; set; } = string.Empty; + + /// Theme hint for this icon. + [JsonPropertyName("theme")] + public string? Theme { get; set; } +} + +/// An MCP prompt descriptor. Server-provided non-standard fields are exposed under `additionalProperties`. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class McpPrompt +{ + /// Prompt-level metadata. + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } + + /// Server-provided non-standard descriptor fields. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Arguments accepted by the prompt. + [JsonPropertyName("arguments")] + public IList? Arguments { get; set; } + + /// Description of what this prompt provides. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Icons associated with this prompt. + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + + /// The programmatic name of the prompt. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display title. + [JsonPropertyName("title")] + public string? Title { get; set; } +} + +/// One page of prompts advertised by the named MCP server. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class McpPromptsListResult +{ + /// MCP result metadata. + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } + + /// Server-provided non-standard result fields. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Opaque cursor for the next page, if the server has more prompts. + [JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } + + /// Prompts advertised by the server. + [JsonPropertyName("prompts")] + public IList Prompts { get => field ??= []; set; } +} + +/// MCP server whose prompts to enumerate. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class McpPromptsListRequest +{ + /// Opaque MCP pagination cursor from a prior `nextCursor` value. + [JsonPropertyName("cursor")] + public string? Cursor { get; set; } + + /// Name of the MCP server whose prompts to enumerate. + [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] + [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)] + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// An MCP prompt message with opaque JSON content preserved without flattening or content-type filtering. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class McpPromptMessage +{ + /// Message-level metadata. + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } + + /// Server-provided non-standard message fields. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// The original MCP content block, including nested metadata and unfamiliar content types. + [JsonPropertyName("content")] + public JsonElement Content { get; set; } + + /// The role of the message sender. + [JsonPropertyName("role")] + public McpPromptRole Role { get; set; } +} + +/// Prompt messages returned by the MCP server without sending them to the model. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class McpPromptsGetResult +{ + /// MCP result metadata. + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } + + /// Server-provided non-standard result fields. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Description of the prompt. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Ordered prompt messages. + [JsonPropertyName("messages")] + public IList Messages { get => field ??= []; set; } +} + +/// MCP server, prompt name, and optional string-valued arguments. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class McpPromptsGetRequest +{ + /// String-valued arguments to pass to the prompt. + [JsonPropertyName("arguments")] + public IDictionary? Arguments { get; set; } + + /// The programmatic name of the prompt. + [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)] + [JsonPropertyName("promptName")] + public string PromptName { get; set; } = string.Empty; + + /// Name of the MCP server hosting the prompt. + [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] + [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)] + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// MCP diagnostic source configuration. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class McpDiagnosticSourceConfiguration @@ -22908,16 +23137,16 @@ internal sealed class ContentExclusionCheckPathsRequest public string SessionId { get; set; } = string.Empty; } -/// Identifier of the spawned process, used to correlate streamed output and exit notifications. +/// Identifier of the spawned shell process, usable with shell.kill while the process is running. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class ShellExecResult { - /// Unique identifier for tracking streamed output. + /// Identifier usable with shell.kill while the process is running. [JsonPropertyName("processId")] public string ProcessId { get; set; } = string.Empty; } -/// Shell command to run, with optional working directory and timeout in milliseconds. +/// Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] internal sealed class ShellExecRequest { @@ -35299,6 +35528,69 @@ public override void Write(Utf8JsonWriter writer, McpAppsHostContextDetailsTheme } +/// The sender role of an MCP prompt message. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpPromptRole : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpPromptRole(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// A message from the user. + public static McpPromptRole User { get; } = new("user"); + + /// A message from the assistant. + public static McpPromptRole Assistant { get; } = new("assistant"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpPromptRole left, McpPromptRole right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpPromptRole left, McpPromptRole right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpPromptRole other && Equals(other); + + /// + public bool Equals(McpPromptRole 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 McpPromptRole Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpPromptRole value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpPromptRole)); + } + } +} + + /// Session-scoped diagnostic threshold. Capture is disabled by default and is never persisted with the session. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -45224,6 +45516,12 @@ public async Task IsServerRunningAsync(string serverNa field ?? Interlocked.CompareExchange(ref field, new(_session), null) ?? field; + + /// Prompts APIs. + public McpPromptsApi Prompts => + field ?? + Interlocked.CompareExchange(ref field, new(_session), null) ?? + field; } /// Provides session-scoped McpOauth APIs. @@ -45303,7 +45601,7 @@ public async Task LoginAsync(string serverName, bool? force } /// Starts OAuth authentication for a remote MCP server. Owned servers require the original one-use prepareLogin handle and exact installation ID; manual servers retain the existing direct login behaviour. - /// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + /// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. /// The to monitor for cancellation requests. The default is . /// OAuth authorization URL the caller should open, or empty when cached tokens already authenticated the server. public async Task LoginAsync(McpOauthLoginRequest request, CancellationToken cancellationToken = default) @@ -45311,10 +45609,24 @@ public async Task LoginAsync(McpOauthLoginRequest request, ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request.ServerName); _session.ThrowIfDisposed(); - var wireRequest = new McpOauthLoginRequestWithSession { SessionId = _session.SessionId, ServerName = request.ServerName, ForceReauth = request.ForceReauth, ClientName = request.ClientName, CallbackSuccessMessage = request.CallbackSuccessMessage, ClientId = request.ClientId, ClientSecret = request.ClientSecret, PublicClient = request.PublicClient, GrantType = request.GrantType, LoginId = request.LoginId, ExpectedInstallationId = request.ExpectedInstallationId }; + var wireRequest = new McpOauthLoginRequestWithSession { SessionId = _session.SessionId, ServerName = request.ServerName, ForceReauth = request.ForceReauth, ClientName = request.ClientName, CallbackSuccessMessage = request.CallbackSuccessMessage, ClientId = request.ClientId, ClientSecret = request.ClientSecret, PublicClient = request.PublicClient, GrantType = request.GrantType, RedirectUri = request.RedirectUri, LoginId = request.LoginId, ExpectedInstallationId = request.ExpectedInstallationId }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.oauth.login", [wireRequest], cancellationToken); } + /// Completes a runtime-managed MCP OAuth login after the authorization server redirects to a host-managed callback URL. + /// Opaque identifier returned by session.mcp.oauth.login for the pending external callback. + /// Full externally visible HTTPS callback URL received by the host, including the authorization response query parameters. Applications behind a reverse proxy must reconstruct the public URL rather than passing an internal proxy URL. + /// The to monitor for cancellation requests. The default is . + public async Task CompleteAsync(string authorizationId, string callbackUrl, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(authorizationId); + ArgumentNullException.ThrowIfNull(callbackUrl); + _session.ThrowIfDisposed(); + + var request = new McpOauthCompleteRequest { SessionId = _session.SessionId, AuthorizationId = authorizationId, CallbackUrl = callbackUrl }; + await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.oauth.complete", [request], cancellationToken); + } + /// Passively probes a configured remote MCP server to classify whether OAuth is required or a cached/override token is accepted. Does not start OAuth, emit pending OAuth requests, or mutate MCP connection state. /// Name of the configured remote MCP server to probe. /// The to monitor for cancellation requests. The default is . @@ -45548,6 +45860,48 @@ public async Task ListTemplatesAsync(string ser } } +/// Provides session-scoped McpPrompts APIs. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class McpPromptsApi +{ + private readonly CopilotSession _session; + + internal McpPromptsApi(CopilotSession session) + { + _session = session; + } + + /// Enumerate one page of prompts a connected MCP server exposes (proxies MCP `prompts/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + /// Name of the MCP server whose prompts to enumerate. + /// Opaque MCP pagination cursor from a prior `nextCursor` value. + /// The to monitor for cancellation requests. The default is . + /// One page of prompts advertised by the named MCP server. + public async Task ListAsync(string serverName, string? cursor = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serverName); + _session.ThrowIfDisposed(); + + var request = new McpPromptsListRequest { SessionId = _session.SessionId, ServerName = serverName, Cursor = cursor }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.prompts.list", [request], cancellationToken); + } + + /// Get a prompt's messages from a connected MCP server (proxies MCP `prompts/get`). Content is preserved as opaque JSON. Does not send messages to the model, execute tools, or fetch referenced resources. + /// Name of the MCP server hosting the prompt. + /// The programmatic name of the prompt. + /// String-valued arguments to pass to the prompt. + /// The to monitor for cancellation requests. The default is . + /// Prompt messages returned by the MCP server without sending them to the model. + public async Task GetAsync(string serverName, string promptName, IDictionary? arguments = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serverName); + ArgumentNullException.ThrowIfNull(promptName); + _session.ThrowIfDisposed(); + + var request = new McpPromptsGetRequest { SessionId = _session.SessionId, ServerName = serverName, PromptName = promptName, Arguments = arguments }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.prompts.get", [request], cancellationToken); + } +} + /// Provides session-scoped Diagnostics APIs. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class DiagnosticsApi @@ -47217,12 +47571,12 @@ internal ShellApi(CopilotSession session) _session = session; } - /// Starts a shell command and streams output through session notifications. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via "shell.kill", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via "setsid") leaves the signalled group, so either can leave a background process running. + /// Starts a shell command, returning an RPC error if it cannot be spawned. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via "shell.kill", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via "setsid") leaves the signalled group, so either can leave a background process running. /// Shell command to execute. /// Working directory (defaults to session working directory). /// Timeout in milliseconds (default: 30000). /// The to monitor for cancellation requests. The default is . - /// Identifier of the spawned process, used to correlate streamed output and exit notifications. + /// Identifier of the spawned shell process, usable with shell.kill while the process is running. public async Task ExecAsync(string command, string? cwd = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(command); @@ -49245,6 +49599,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(McpListToolsRequest))] [JsonSerializable(typeof(McpListToolsResult))] [JsonSerializable(typeof(McpOauthAuthenticationStateChangedRequest))] +[JsonSerializable(typeof(McpOauthCompleteRequest))] [JsonSerializable(typeof(McpOauthHandlePendingRequest))] [JsonSerializable(typeof(McpOauthHandlePendingResult))] [JsonSerializable(typeof(McpOauthLoginRequest))] @@ -49270,6 +49625,14 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(McpPlanUninstallRequest))] [JsonSerializable(typeof(McpPrepareInstallRequest))] [JsonSerializable(typeof(McpPreparedInstall))] +[JsonSerializable(typeof(McpPrompt))] +[JsonSerializable(typeof(McpPromptArgument))] +[JsonSerializable(typeof(McpPromptIcon))] +[JsonSerializable(typeof(McpPromptMessage))] +[JsonSerializable(typeof(McpPromptsGetRequest))] +[JsonSerializable(typeof(McpPromptsGetResult))] +[JsonSerializable(typeof(McpPromptsListRequest))] +[JsonSerializable(typeof(McpPromptsListResult))] [JsonSerializable(typeof(McpRegisterExternalClientRequest))] [JsonSerializable(typeof(McpReloadWithConfigRequest))] [JsonSerializable(typeof(McpRemoveGitHubResult))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 29cd2d23fa..86b804f15a 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -4625,6 +4625,12 @@ public sealed partial class PromptCacheBreakData [JsonPropertyName("toolsRedefined")] internal string[]? ToolsRedefined { get; set; } + /// Changed definition parts of redefined tools, as `tool:part` entries; property-level parts only for telemetry-safe tools, whose other names are hashed. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonInclude] + [JsonPropertyName("toolsRedefinedParts")] + internal string[]? ToolsRedefinedParts { get; set; } + /// Raw names of tools redefined since the prior call, restricted because a tool name can be user-authored. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonInclude] @@ -5000,6 +5006,12 @@ public sealed partial class ToolExecutionProgressData [JsonPropertyName("progressMessage")] public required string ProgressMessage { get; set; } + /// Client-only structured progress metadata. Not model-facing tool output. + [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("structuredContent")] + public JsonElement? StructuredContent { get; set; } + /// Tool call ID this progress notification belongs to. [JsonPropertyName("toolCallId")] public required string ToolCallId { get; set; } @@ -6427,6 +6439,11 @@ public sealed partial class SessionAutoModeResolvedData [JsonPropertyName("routingMethod")] public string? RoutingMethod { get; set; } + /// Short human-readable sentence from the routing service explaining why this model was chosen, for display alongside the model. Present only when the service supplied one: it is omitted for on-device selections, when the service did not provide an explanation, and when a replayed decision made no routing call. The text is display-only and drawn from a fixed catalogue; several distinct routing categories share identical wording, so it cannot be used to recover the category or keyed on programmatically. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("selectionReason")] + public string? SelectionReason { get; set; } + /// Whether a sticky model choice overrode the router result. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("stickyOverride")] diff --git a/dotnet/test/Unit/McpPromptsTests.cs b/dotnet/test/Unit/McpPromptsTests.cs new file mode 100644 index 0000000000..918680594c --- /dev/null +++ b/dotnet/test/Unit/McpPromptsTests.cs @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using GitHub.Copilot.Rpc; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +/// Generated result fidelity complements the real MCP transport E2E. +public class McpPromptsTests +{ + private static readonly JsonSerializerOptions Options = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + [Theory] + [InlineData("firstPage")] + [InlineData("secondPage")] + [InlineData("richPrompt")] + public void Results_Preserve_Optional_Fields_And_Opaque_Content(string fixtureName) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + string? fixturePath = null; + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, "test", "harness", "mcp-prompt-fixtures.json"); + if (File.Exists(candidate)) + { + fixturePath = candidate; + break; + } + directory = directory.Parent; + } + Assert.NotNull(fixturePath); + var expected = JsonNode.Parse(File.ReadAllText(fixturePath))![fixtureName]!; + var actual = fixtureName == "richPrompt" + ? JsonSerializer.SerializeToNode(expected.Deserialize(Options), Options) + : JsonSerializer.SerializeToNode(expected.Deserialize(Options), Options); + Assert.True(JsonNode.DeepEquals(expected, actual), $"Expected: {expected}\nActual: {actual}"); + } +} diff --git a/dotnet/test/Unit/RpcMcpPromptsTests.cs b/dotnet/test/Unit/RpcMcpPromptsTests.cs new file mode 100644 index 0000000000..ae18b70c27 --- /dev/null +++ b/dotnet/test/Unit/RpcMcpPromptsTests.cs @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public sealed partial class ClientSessionLifetimeTests +{ + [Fact] + public async Task Mcp_Prompts_Bind_Session_And_Preserve_Omitted_And_Empty_Arguments() + { + // TypeScript owns runtime behavior; this fixture checks .NET wire encoding. + await using var server = await FakeCopilotServer.StartAsync(); + server.ResponseFactory = request => request.Method switch + { + "session.mcp.prompts.list" => new Dictionary + { + ["prompts"] = Array.Empty(), + }, + "session.mcp.prompts.get" => new Dictionary + { + ["messages"] = Array.Empty(), + }, + _ => throw new InvalidOperationException($"Unexpected prompt request '{request.Method}'."), + }; + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url) + }); + await using var session = await client.CreateSessionAsync(new SessionConfig()); + server.ClearRequests(); + + Assert.Empty((await session.Rpc.Mcp.Prompts.ListAsync("fixture")).Prompts); + await session.Rpc.Mcp.Prompts.ListAsync("fixture", "page:2/opaque+cursor="); + Assert.Empty((await session.Rpc.Mcp.Prompts.GetAsync("fixture", "echo")).Messages); + await session.Rpc.Mcp.Prompts.GetAsync("fixture", "echo", new Dictionary()); + await session.Rpc.Mcp.Prompts.GetAsync("fixture", "echo", + new Dictionary { ["topic"] = "日本語", ["style"] = "" }); + + var requests = server.Requests.ToArray(); + Assert.Equal(5, requests.Length); + for (var i = 0; i < requests.Length; i++) + { + var request = requests[i]; + Assert.Equal(i < 2 ? "session.mcp.prompts.list" : "session.mcp.prompts.get", request.Method); + Assert.Equal(session.SessionId, request.Params.GetProperty("sessionId").GetString()); + Assert.Equal("fixture", request.Params.GetProperty("serverName").GetString()); + if (i >= 2) + { + Assert.Equal("echo", request.Params.GetProperty("promptName").GetString()); + } + } + Assert.False(requests[0].Params.TryGetProperty("cursor", out _)); + Assert.Equal("page:2/opaque+cursor=", requests[1].Params.GetProperty("cursor").GetString()); + Assert.False(requests[2].Params.TryGetProperty("arguments", out _)); + Assert.Empty(requests[3].Params.GetProperty("arguments").EnumerateObject()); + var arguments = requests[4].Params.GetProperty("arguments"); + Assert.Equal("日本語", arguments.GetProperty("topic").GetString()); + Assert.Equal("", arguments.GetProperty("style").GetString()); + } +} +#endif diff --git a/go/rpc/mcp_prompts_test.go b/go/rpc/mcp_prompts_test.go new file mode 100644 index 0000000000..14cbe69fc9 --- /dev/null +++ b/go/rpc/mcp_prompts_test.go @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +package rpc + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// Generated serialization supplements the real MCP boundary tests in Node and .NET. +func TestMCPPromptResultsPreserveOpaqueJSON(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "test", "harness", "mcp-prompt-fixtures.json")) + if err != nil { + t.Fatal(err) + } + var fixtures map[string]json.RawMessage + if err := json.Unmarshal(raw, &fixtures); err != nil { + t.Fatal(err) + } + for _, name := range []string{"firstPage", "secondPage", "richPrompt"} { + t.Run(name, func(t *testing.T) { + var result any = &MCPPromptsListResult{} + if name == "richPrompt" { + result = &MCPPromptsGetResult{} + } + if err := json.Unmarshal(fixtures[name], result); err != nil { + t.Fatal(err) + } + assertPromptJSON(t, result, fixtures[name]) + }) + } +} + +func TestMCPPromptRequestsPreserveOptionalArguments(t *testing.T) { + for _, wire := range []string{ + `{"serverName":"fixture","promptName":"rich"}`, + `{"serverName":"fixture","promptName":"rich","arguments":{}}`, + `{"serverName":"fixture","promptName":"rich","arguments":{"topic":"日本語","style":""}}`, + } { + t.Run(wire, func(t *testing.T) { + var request MCPPromptsGetRequest + if err := json.Unmarshal([]byte(wire), &request); err != nil { + t.Fatal(err) + } + assertPromptJSON(t, request, []byte(wire)) + }) + } + for _, wire := range []string{ + `{"serverName":"fixture"}`, + `{"serverName":"fixture","cursor":"page:2/opaque+cursor="}`, + } { + var request MCPPromptsListRequest + if err := json.Unmarshal([]byte(wire), &request); err != nil { + t.Fatal(err) + } + assertPromptJSON(t, request, []byte(wire)) + } +} + +func assertPromptJSON(t *testing.T, value any, expected []byte) { + t.Helper() + actual, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var got, want any + if err := json.Unmarshal(actual, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(expected, &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("JSON mismatch\n got: %s\nwant: %s", actual, expected) + } +} diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 6a0f8c826f..992acaa630 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -7636,6 +7636,18 @@ type MCPOauthCancelLoginResult struct { Cancelled bool `json:"cancelled"` } +// Host-delivered callback for a runtime-managed MCP OAuth login. +// Experimental: MCPOauthCompleteRequest is part of an experimental API and may change or be +// removed. +type MCPOauthCompleteRequest struct { + // Opaque identifier returned by session.mcp.oauth.login for the pending external callback. + AuthorizationID string `json:"authorizationId"` + // Full externally visible HTTPS callback URL received by the host, including the + // authorization response query parameters. Applications behind a reverse proxy must + // reconstruct the public URL rather than passing an internal proxy URL. + CallbackURL string `json:"callbackUrl"` +} + // Pending MCP OAuth request ID and host-provided token or cancellation response. // Experimental: MCPOauthHandlePendingRequest is part of an experimental API and may change // or be removed. @@ -7656,7 +7668,7 @@ type MCPOauthHandlePendingResult struct { } // Remote MCP server name and optional overrides controlling reauthentication, OAuth client -// display name, callback success-page copy, and static OAuth client selection. +// display name, callback handling, and static OAuth client selection. // Experimental: MCPOauthLoginRequest is part of an experimental API and may change or be // removed. type MCPOauthLoginRequest struct { @@ -7693,6 +7705,12 @@ type MCPOauthLoginRequest struct { // runtime treats it as confidential and uses the per-login clientSecret if provided, // otherwise retrieving the client secret from the MCP OAuth secret store. PublicClient *bool `json:"publicClient,omitempty"` + // Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When + // supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and + // reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not + // contain query parameters or a fragment and must be registered for the selected CIMD, DCR, + // or static OAuth client. + RedirectURI *string `json:"redirectUri,omitempty"` // Name of the remote MCP server to authenticate ServerName string `json:"serverName"` } @@ -7702,11 +7720,16 @@ type MCPOauthLoginRequest struct { // Experimental: MCPOauthLoginResult is part of an experimental API and may change or be // removed. type MCPOauthLoginResult struct { + // Opaque authorization identifier returned only for a host-managed redirect URI. The + // runtime also sends it as the OAuth state value, so the callback endpoint can read state + // and pass it with the full callback URL to session.mcp.oauth.complete. + AuthorizationID *string `json:"authorizationId,omitempty"` // URL the caller should open in a browser to complete OAuth. Omitted when cached tokens // were still valid and no browser interaction was needed — the server is already - // reconnected in that case. When present, the runtime starts the callback listener before - // returning and continues the flow in the background; completion is signaled via - // session.mcp_server_status_changed. + // reconnected in that case. For the default loopback flow, the runtime starts its listener + // before returning. With redirectUri, the host receives the callback and completes it + // through session.mcp.oauth.complete. The runtime continues the flow in the background and + // signals completion via session.mcp_server_status_changed. AuthorizationURL *string `json:"authorizationUrl,omitempty"` // Runtime-issued owned flow identity; never a server name or installation operation ID. LoginID *string `json:"loginId,omitempty"` @@ -8359,6 +8382,122 @@ type MCPPrepareInstallRequest struct { Source MCPServerCardReference `json:"source"` } +// An MCP prompt descriptor. Server-provided non-standard fields are exposed under +// `additionalProperties`. +// Experimental: MCPPrompt is part of an experimental API and may change or be removed. +type MCPPrompt struct { + // Server-provided non-standard descriptor fields + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Arguments accepted by the prompt + Arguments []MCPPromptArgument `json:"arguments,omitzero"` + // Description of what this prompt provides + Description *string `json:"description,omitempty"` + // Icons associated with this prompt + Icons []MCPPromptIcon `json:"icons,omitzero"` + // Prompt-level metadata + Meta map[string]any `json:"_meta,omitzero"` + // The programmatic name of the prompt + Name string `json:"name"` + // Human-readable display title + Title *string `json:"title,omitempty"` +} + +// An argument accepted by an MCP prompt. +// Experimental: MCPPromptArgument is part of an experimental API and may change or be +// removed. +type MCPPromptArgument struct { + // Server-provided non-standard argument fields + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Description of the argument + Description *string `json:"description,omitempty"` + // Argument-level metadata + Meta map[string]any `json:"_meta,omitzero"` + // Name of the argument + Name string `json:"name"` + // Whether the argument is required; omission is distinct from false + Required *bool `json:"required,omitempty"` +} + +// An MCP prompt icon with standard size hints and preserved non-standard fields. +// Experimental: MCPPromptIcon is part of an experimental API and may change or be removed. +type MCPPromptIcon struct { + // Server-provided non-standard icon fields + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Icon MIME type, when known + MIMEType *string `json:"mimeType,omitempty"` + // Icon sizes, such as `48x48` or `any` + Sizes []string `json:"sizes,omitzero"` + // Icon URI + Src string `json:"src"` + // Theme hint for this icon + Theme *string `json:"theme,omitempty"` +} + +// An MCP prompt message with opaque JSON content preserved without flattening or +// content-type filtering. +// Experimental: MCPPromptMessage is part of an experimental API and may change or be +// removed. +type MCPPromptMessage struct { + // Server-provided non-standard message fields + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // The original MCP content block, including nested metadata and unfamiliar content types + Content any `json:"content"` + // Message-level metadata + Meta map[string]any `json:"_meta,omitzero"` + // The role of the message sender + Role MCPPromptRole `json:"role"` +} + +// MCP server, prompt name, and optional string-valued arguments. +// Experimental: MCPPromptsGetRequest is part of an experimental API and may change or be +// removed. +type MCPPromptsGetRequest struct { + // String-valued arguments to pass to the prompt + Arguments map[string]string `json:"arguments,omitzero"` + // The programmatic name of the prompt + PromptName string `json:"promptName"` + // Name of the MCP server hosting the prompt + ServerName string `json:"serverName"` +} + +// Prompt messages returned by the MCP server without sending them to the model. +// Experimental: MCPPromptsGetResult is part of an experimental API and may change or be +// removed. +type MCPPromptsGetResult struct { + // Server-provided non-standard result fields + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Description of the prompt + Description *string `json:"description,omitempty"` + // Ordered prompt messages + Messages []MCPPromptMessage `json:"messages"` + // MCP result metadata + Meta map[string]any `json:"_meta,omitzero"` +} + +// MCP server whose prompts to enumerate. +// Experimental: MCPPromptsListRequest is part of an experimental API and may change or be +// removed. +type MCPPromptsListRequest struct { + // Opaque MCP pagination cursor from a prior `nextCursor` value + Cursor *string `json:"cursor,omitempty"` + // Name of the MCP server whose prompts to enumerate + ServerName string `json:"serverName"` +} + +// One page of prompts advertised by the named MCP server. +// Experimental: MCPPromptsListResult is part of an experimental API and may change or be +// removed. +type MCPPromptsListResult struct { + // Server-provided non-standard result fields + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // MCP result metadata + Meta map[string]any `json:"_meta,omitzero"` + // Opaque cursor for the next page, if the server has more prompts + NextCursor *string `json:"nextCursor,omitempty"` + // Prompts advertised by the server + Prompts []MCPPrompt `json:"prompts"` +} + // Registration parameters for an external MCP client. // Experimental: MCPRegisterExternalClientRequest is part of an experimental API and may // change or be removed. @@ -14924,6 +15063,11 @@ type SessionMCPOauthCancelLoginResult struct { Cancelled bool `json:"cancelled"` } +// Experimental: SessionMCPOauthCompleteResult is part of an experimental API and may change +// or be removed. +type SessionMCPOauthCompleteResult struct { +} + // Effect-free preparation bound to the existing local session, requester and installation, // with frozen options. // Experimental: SessionMCPOauthPrepareLoginRequest is part of an experimental API and may @@ -16749,7 +16893,8 @@ type ShellCredentials struct { Git *bool `json:"git,omitempty"` } -// Shell command to run, with optional working directory and timeout in milliseconds. +// Shell command to run, with optional working directory and timeout in milliseconds. Spawn +// failures return an RPC error. // Experimental: ShellExecRequest is part of an experimental API and may change or be // removed. type ShellExecRequest struct { @@ -16761,11 +16906,11 @@ type ShellExecRequest struct { Timeout *int64 `json:"timeout,omitempty"` } -// Identifier of the spawned process, used to correlate streamed output and exit -// notifications. +// Identifier of the spawned shell process, usable with shell.kill while the process is +// running. // Experimental: ShellExecResult is part of an experimental API and may change or be removed. type ShellExecResult struct { - // Unique identifier for tracking streamed output + // Identifier usable with shell.kill while the process is running ProcessID string `json:"processId"` } @@ -23368,6 +23513,17 @@ const ( MCPPlanValueCategoryURLVariable MCPPlanValueCategory = "url-variable" ) +// The sender role of an MCP prompt message. +// Experimental: MCPPromptRole is part of an experimental API and may change or be removed. +type MCPPromptRole string + +const ( + // A message from the assistant. + MCPPromptRoleAssistant MCPPromptRole = "assistant" + // A message from the user. + MCPPromptRoleUser MCPPromptRole = "user" +) + // Outcome of the sampling inference. 'success' produced a response; 'failure' encountered // an error (including agent-side rejection by content filter or criteria); 'cancelled' the // caller cancelled this execution via cancelSamplingExecution. @@ -31014,6 +31170,29 @@ func (a *MCPOauthAPI) CancelLogin(ctx context.Context, params *SessionMCPOauthCa return &result, nil } +// Completes a runtime-managed MCP OAuth login after the authorization server redirects to a +// host-managed callback URL. +// +// RPC method: session.mcp.oauth.complete. +// +// Parameters: Host-delivered callback for a runtime-managed MCP OAuth login. +func (a *MCPOauthAPI) Complete(ctx context.Context, params *MCPOauthCompleteRequest) (*SessionMCPOauthCompleteResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + req["authorizationId"] = params.AuthorizationID + req["callbackUrl"] = params.CallbackURL + } + raw, err := a.client.Request(ctx, "session.mcp.oauth.complete", req) + if err != nil { + return nil, err + } + var result SessionMCPOauthCompleteResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // HandlePendingRequest resolves a pending MCP OAuth request with a host-provided token or // cancellation. The pending request is emitted as mcp.oauth_required with the data // necessary to authorize the request. @@ -31047,7 +31226,7 @@ func (a *MCPOauthAPI) HandlePendingRequest(ctx context.Context, params *MCPOauth // RPC method: session.mcp.oauth.login. // // Parameters: Remote MCP server name and optional overrides controlling reauthentication, -// OAuth client display name, callback success-page copy, and static OAuth client selection. +// OAuth client display name, callback handling, and static OAuth client selection. // // Returns: OAuth authorization URL the caller should open, or empty when cached tokens // already authenticated the server. @@ -31081,6 +31260,9 @@ func (a *MCPOauthAPI) Login(ctx context.Context, params *MCPOauthLoginRequest) ( if params.PublicClient != nil { req["publicClient"] = *params.PublicClient } + if params.RedirectURI != nil { + req["redirectUri"] = *params.RedirectURI + } req["serverName"] = params.ServerName } raw, err := a.client.Request(ctx, "session.mcp.oauth.login", req) @@ -31191,6 +31373,70 @@ func (s *MCPAPI) Oauth() *MCPOauthAPI { return (*MCPOauthAPI)(s) } +// Experimental: MCPPromptsAPI contains experimental APIs that may change or be removed. +type MCPPromptsAPI sessionAPI + +// Get a prompt's messages from a connected MCP server (proxies MCP `prompts/get`). Content +// is preserved as opaque JSON. Does not send messages to the model, execute tools, or fetch +// referenced resources. +// +// RPC method: session.mcp.prompts.get. +// +// Parameters: MCP server, prompt name, and optional string-valued arguments. +// +// Returns: Prompt messages returned by the MCP server without sending them to the model. +func (a *MCPPromptsAPI) Get(ctx context.Context, params *MCPPromptsGetRequest) (*MCPPromptsGetResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + if params.Arguments != nil { + req["arguments"] = params.Arguments + } + req["promptName"] = params.PromptName + req["serverName"] = params.ServerName + } + raw, err := a.client.Request(ctx, "session.mcp.prompts.get", req) + if err != nil { + return nil, err + } + var result MCPPromptsGetResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// List enumerate one page of prompts a connected MCP server exposes (proxies MCP +// `prompts/list`). Pass `cursor` to continue from a prior result's `nextCursor`. +// +// RPC method: session.mcp.prompts.list. +// +// Parameters: MCP server whose prompts to enumerate. +// +// Returns: One page of prompts advertised by the named MCP server. +func (a *MCPPromptsAPI) List(ctx context.Context, params *MCPPromptsListRequest) (*MCPPromptsListResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + if params.Cursor != nil { + req["cursor"] = *params.Cursor + } + req["serverName"] = params.ServerName + } + raw, err := a.client.Request(ctx, "session.mcp.prompts.list", req) + if err != nil { + return nil, err + } + var result MCPPromptsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Experimental: Prompts returns experimental APIs that may change or be removed. +func (s *MCPAPI) Prompts() *MCPPromptsAPI { + return (*MCPPromptsAPI)(s) +} + // Experimental: MCPResourcesAPI contains experimental APIs that may change or be removed. type MCPResourcesAPI sessionAPI @@ -33781,7 +34027,7 @@ func (a *ShellAPI) CancelUserRequested(ctx context.Context, params *ShellCancelU return &result, nil } -// Exec starts a shell command and streams output through session notifications. The command +// Exec starts a shell command, returning an RPC error if it cannot be spawned. The command // runs as the leader of its own process group (POSIX) or in a dedicated job object // (Windows), so a forced termination — via "shell.kill", the request timeout, or session // disposal — signals that whole group/job rather than only the direct child. Two gaps are @@ -33793,10 +34039,10 @@ func (a *ShellAPI) CancelUserRequested(ctx context.Context, params *ShellCancelU // RPC method: session.shell.exec. // // Parameters: Shell command to run, with optional working directory and timeout in -// milliseconds. +// milliseconds. Spawn failures return an RPC error. // -// Returns: Identifier of the spawned process, used to correlate streamed output and exit -// notifications. +// Returns: Identifier of the spawned shell process, usable with shell.kill while the +// process is running. func (a *ShellAPI) Exec(ctx context.Context, params *ShellExecRequest) (*ShellExecResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 1c8c416632..628546b1eb 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -326,6 +326,9 @@ type PromptCacheBreakData struct { // Telemetry-safe names of tools whose definition changed since the prior call // Internal: ToolsRedefined is part of the SDK's internal API surface and is not intended for external use. ToolsRedefined []string `json:"toolsRedefined,omitzero"` + // Changed definition parts of redefined tools, as `tool:part` entries; property-level parts only for telemetry-safe tools, whose other names are hashed + // Internal: ToolsRedefinedParts is part of the SDK's internal API surface and is not intended for external use. + ToolsRedefinedParts []string `json:"toolsRedefinedParts,omitzero"` // Raw names of tools redefined since the prior call, restricted because a tool name can be user-authored // Internal: ToolsRedefinedRaw is part of the SDK's internal API surface and is not intended for external use. ToolsRedefinedRaw []string `json:"toolsRedefinedRaw,omitzero"` @@ -503,6 +506,8 @@ type SessionAutoModeResolvedData struct { RouterLatencyMs *float64 `json:"routerLatencyMs,omitempty"` // The routing method the server applied, when Auto Intent ran RoutingMethod *string `json:"routingMethod,omitempty"` + // Short human-readable sentence from the routing service explaining why this model was chosen, for display alongside the model. Present only when the service supplied one: it is omitted for on-device selections, when the service did not provide an explanation, and when a replayed decision made no routing call. The text is display-only and drawn from a fixed catalogue; several distinct routing categories share identical wording, so it cannot be used to recover the category or keyed on programmatically. + SelectionReason *string `json:"selectionReason,omitempty"` // Whether a sticky model choice overrode the router result StickyOverride *bool `json:"stickyOverride,omitempty"` } @@ -3115,6 +3120,9 @@ func (*ToolExecutionCompleteData) Type() SessionEventType { type ToolExecutionProgressData struct { // Human-readable progress status message (e.g., from an MCP server) ProgressMessage string `json:"progressMessage"` + // Client-only structured progress metadata. Not model-facing tool output. + // Experimental: StructuredContent is part of an experimental API and may change or be removed. + StructuredContent any `json:"structuredContent,omitempty"` // Tool call ID this progress notification belongs to ToolCallID string `json:"toolCallId"` } diff --git a/java/README.md b/java/README.md index 001383b9c6..bb92bdd4ee 100644 --- a/java/README.md +++ b/java/README.md @@ -631,6 +631,11 @@ observers share the failed future. Some SDK APIs are marked as experimental with `@CopilotExperimental`. These APIs may change or be removed in future versions without notice. +An otherwise stable session event can contain experimental properties. Generated +event payload records preserve each property's schema stability marker on its +record component and generated accessor; the enclosing event and its other +properties remain stable. + By default, referencing an experimental API from your code causes a **compile-time error**: ``` diff --git a/java/scripts/codegen/java.test.ts b/java/scripts/codegen/java.test.ts index 8e10669e81..294b8ed27f 100644 --- a/java/scripts/codegen/java.test.ts +++ b/java/scripts/codegen/java.test.ts @@ -1,3 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -72,6 +76,40 @@ function renderPayload(dataSchema: JSONSchema7): string { }, "com.github.copilot.generated"); } +test("experimental event properties retain their marker without marking the stable event", () => { + const structuredContent: JSONSchema7 & { stability: string } = { + description: "Client-only structured progress metadata.", + stability: "experimental", + }; + const source = renderPayload({ + type: "object", + properties: { + toolCallId: { type: "string" }, + progressMessage: { type: "string" }, + structuredContent, + }, + required: ["toolCallId", "progressMessage"], + }); + + assert.match(source, /import com\.github\.copilot\.CopilotExperimental;/); + assert.match(source, /@CopilotExperimental\s+@JsonProperty\("structuredContent"\) Object structuredContent/); + assert.deepEqual(source.match(/@CopilotExperimental/g), ["@CopilotExperimental"]); + assert.match(source, /@JsonProperty\("progressMessage"\) String progressMessage,/); +}); + +test("stable event properties do not acquire experimental markers", () => { + const source = renderPayload({ + type: "object", + properties: { + progressMessage: { type: "string", description: "Experimental is ordinary text here." }, + structuredContent: { description: "Client-only structured progress metadata." }, + }, + }); + + assert.match(source, /@JsonProperty\("structuredContent"\) Object structuredContent/); + assert.doesNotMatch(source, /CopilotExperimental/); +}); + for (const keyword of ["anyOf", "oneOf"] as const) { test(`root ${keyword} payload preserves raw JSON and existing data descriptors`, () => { const source = renderPayload({ diff --git a/java/scripts/codegen/java.ts b/java/scripts/codegen/java.ts index 1b26fbc7ac..cd21f66bf5 100644 --- a/java/scripts/codegen/java.ts +++ b/java/scripts/codegen/java.ts @@ -1754,6 +1754,7 @@ export function renderEventVariantClass(variant: EventVariant, packageName: stri javaName: string; javaType: string; description?: string; + experimental: boolean; } const dataFields: FieldInfo[] = []; @@ -1770,6 +1771,7 @@ export function renderEventVariantClass(variant: EventVariant, packageName: stri javaName: toCamelCase(propName), javaType: result.javaType, description: prop.description, + experimental: isSchemaExperimental(prop), }); } } @@ -1855,6 +1857,10 @@ export function renderEventVariantClass(variant: EventVariant, packageName: stri if (field.description) { lines.push(` /** ${field.description} */`); } + if (field.experimental) { + allImports.add("com.github.copilot.CopilotExperimental"); + lines.push(` @CopilotExperimental`); + } lines.push(` @JsonProperty("${field.jsonName}") ${field.javaType} ${field.javaName}${comma}`); } lines.push(` ) {`); diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseCompletedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseCompletedEvent.java index d317f93a0e..27b144b281 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseCompletedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseCompletedEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -47,6 +48,7 @@ public record AssistantFusionPhaseCompletedEventData( /** Concrete model that executed the phase. */ @JsonProperty("model") String model, /** Explicit reasoning effort selected for this phase, if supplied. */ + @CopilotExperimental @JsonProperty("reasoningEffort") String reasoningEffort, /** Durable outcome status of the phase. */ @JsonProperty("status") FusionPhaseStatus status, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseFailedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseFailedEvent.java index c222e0e0c3..e248d0a9fe 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseFailedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseFailedEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -47,6 +48,7 @@ public record AssistantFusionPhaseFailedEventData( /** Concrete model that attempted the phase. */ @JsonProperty("model") String model, /** Explicit reasoning effort selected for this phase, if supplied. */ + @CopilotExperimental @JsonProperty("reasoningEffort") String reasoningEffort, /** Durable outcome status of the phase. */ @JsonProperty("status") FusionPhaseStatus status, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseStartedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseStartedEvent.java index 9408e87c2b..28ed76f283 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseStartedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantFusionPhaseStartedEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -49,6 +50,7 @@ public record AssistantFusionPhaseStartedEventData( /** Concrete model executing the phase. */ @JsonProperty("model") String model, /** Explicit reasoning effort selected for this phase, if supplied. */ + @CopilotExperimental @JsonProperty("reasoningEffort") String reasoningEffort ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java index d3154846ed..365b945965 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; import java.util.List; import javax.annotation.processing.Generated; @@ -82,8 +83,10 @@ public record AssistantMessageEventData( /** Tool call ID of the parent tool invocation when this event originates from a sub-agent */ @JsonProperty("parentToolCallId") String parentToolCallId, /** Provider-agnostic citations linking spans of this message's content to the sources that support them. Experimental; only populated when citation emission is enabled. */ + @CopilotExperimental @JsonProperty("citations") Citations citations, /** Experimental HydraFusion source attribution for this ordinary authoritative assistant message. */ + @CopilotExperimental @JsonProperty("fusion") FusionAttribution fusion ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantUsageEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantUsageEvent.java index a2e08bfb14..b37d001a96 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/AssistantUsageEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/AssistantUsageEvent.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; @@ -56,6 +57,7 @@ public record AssistantUsageEventData( /** Recognized provider-reported reasons for dropped thinking blocks, in response order */ @JsonProperty("thinkingDroppedReasons") List thinkingDroppedReasons, /** Model multiplier cost for billing purposes */ + @CopilotExperimental @JsonProperty("cost") Double cost, /** Duration of the API call in milliseconds */ @JsonProperty("duration") Long duration, @@ -122,6 +124,7 @@ public record AssistantUsageEventData( /** Whether the model response was blocked or truncated by content filtering (finish_reason === 'content_filter'). For Anthropic models this corresponds to a 'refusal' stop reason. */ @JsonProperty("contentFilterTriggered") Boolean contentFilterTriggered, /** Experimental HydraFusion attribution for this concrete model call's usage. */ + @CopilotExperimental @JsonProperty("fusion") FusionAttribution fusion ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallFailureEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallFailureEvent.java index 8e3e166f04..a9a506f18c 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallFailureEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallFailureEvent.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; import java.util.Map; import javax.annotation.processing.Generated; @@ -86,6 +87,7 @@ public record ModelCallFailureEventData( /** Content-free structural summary of the failing request. Contains only counts and shape flags (no prompt content), so it is safe for unrestricted telemetry. Populated only for client-error (4xx) failures. */ @JsonProperty("requestFingerprint") ModelCallFailureRequestFingerprint requestFingerprint, /** Experimental HydraFusion attribution for this failed concrete model call. */ + @CopilotExperimental @JsonProperty("fusion") FusionAttribution fusion ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallStartEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallStartEvent.java index a3db0aadbe..0e76806f41 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallStartEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/ModelCallStartEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -41,6 +42,7 @@ public record ModelCallStartEventData( /** Previous response or interaction identifier included in the model request, when present */ @JsonProperty("previousResponseId") String previousResponseId, /** Experimental HydraFusion attribution for this concrete model call. */ + @CopilotExperimental @JsonProperty("fusion") FusionAttribution fusion, /** Parent task tool call ID when this model call belongs to a sub-agent */ @JsonProperty("parentToolCallId") String parentToolCallId diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionAssentDetectedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionAssentDetectedEvent.java index 0f3453c45a..84ccf7473e 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionAssentDetectedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionAssentDetectedEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -35,8 +36,10 @@ public final class PermissionAssentDetectedEvent extends SessionEvent { @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionAssentDetectedEventData( /** Permission request the likely assent may refer to. The runtime derives this from the preceding durable blocker; the human message and extraction model do not choose it. */ + @CopilotExperimental @JsonProperty("requestId") String requestId, /** Human turn whose text triggered the deterministic assent recognizer. */ + @CopilotExperimental @JsonProperty("turnIndex") Long turnIndex ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCarriedForwardEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCarriedForwardEvent.java index a89b59660d..e9126e2308 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCarriedForwardEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCarriedForwardEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -35,12 +36,16 @@ public final class PermissionCarriedForwardEvent extends SessionEvent { @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionCarriedForwardEventData( /** Authorization edge minted for this admission. Not a prompt id: no prompt was raised, so no client should expect a request with this id. */ + @CopilotExperimental @JsonProperty("requestId") String requestId, /** Tool call this admission authorizes. Its execution receipts the prior grant, which is how a single-effect approval is spent rather than carried forward again. */ + @CopilotExperimental @JsonProperty("toolCallId") String toolCallId, /** Identity of the prior authorization record that contained the proposal. */ + @CopilotExperimental @JsonProperty("recordId") String recordId, /** Always `authorization_carry_forward`. Stated explicitly so a consumer reading this event cannot mistake it for a human, host-policy, or assisted-approval decision. */ + @CopilotExperimental @JsonProperty("decisionSource") PermissionDecisionSource decisionSource ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCompletedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCompletedEvent.java index b6da6bfb05..2cafbcafb8 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCompletedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionCompletedEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -45,6 +46,7 @@ public record PermissionCompletedEventData( /** The result of the permission request */ @JsonProperty("result") Object result, /** Who decided this permission request. Absent on completions recorded before this field existed, which consumers must treat as "not a human decision" rather than assuming one. Authorization records are minted only for `human_response`; an assisted-approval verdict, a host policy, an unattended fallback, and a hook resolution all produce the same `result` a person does, so this is the only field that distinguishes them. */ + @CopilotExperimental @JsonProperty("decisionSource") PermissionDecisionSource decisionSource ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionContextualAuthorizationEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionContextualAuthorizationEvent.java index 0712d39db8..80bc8cd24e 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionContextualAuthorizationEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionContextualAuthorizationEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -35,16 +36,22 @@ public final class PermissionContextualAuthorizationEvent extends SessionEvent { @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionContextualAuthorizationEventData( /** Deterministic identity of the contextual message grant. */ + @CopilotExperimental @JsonProperty("recordId") String recordId, /** Original blocked permission request selected by deterministic event ordering, never by the extraction model. */ + @CopilotExperimental @JsonProperty("requestId") String requestId, /** Human turn containing the contextual decision. */ + @CopilotExperimental @JsonProperty("turnIndex") Long turnIndex, /** Whether the contextual human span granted or denied authority. */ + @CopilotExperimental @JsonProperty("polarity") PermissionMessageAuthorizationPolarity polarity, /** Start byte offset of the contextual decision span within the turn. */ + @CopilotExperimental @JsonProperty("spanStart") Long spanStart, /** End byte offset of the contextual decision span within the turn. */ + @CopilotExperimental @JsonProperty("spanEnd") Long spanEnd ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationDegradedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationDegradedEvent.java index 8efdf509a6..dc49be3971 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationDegradedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationDegradedEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -35,6 +36,7 @@ public final class PermissionMessageAuthorizationDegradedEvent extends SessionEv @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionMessageAuthorizationDegradedEventData( /** The human turn that could not be represented safely. */ + @CopilotExperimental @JsonProperty("turnIndex") Long turnIndex ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationEvent.java index 3a6c5aa6f0..88ff87b9dd 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationEvent.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; import java.util.List; import javax.annotation.processing.Generated; @@ -36,22 +37,31 @@ public final class PermissionMessageAuthorizationEvent extends SessionEvent { @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionMessageAuthorizationEventData( /** Deterministic identity of the record, derived from the turn and span offsets so re-extracting the same span mints nothing new. */ + @CopilotExperimental @JsonProperty("recordId") String recordId, /** The human turn the quoted span was read from. */ + @CopilotExperimental @JsonProperty("turnIndex") Long turnIndex, /** Whether the claim granted or denied authority. */ + @CopilotExperimental @JsonProperty("polarity") PermissionMessageAuthorizationPolarity polarity, /** The kind of effect authorized, as an action-class identifier. */ + @CopilotExperimental @JsonProperty("actionClass") String actionClass, /** Start byte offset of the authorizing span within the turn. */ + @CopilotExperimental @JsonProperty("spanStart") Long spanStart, /** End byte offset of the authorizing span within the turn. */ + @CopilotExperimental @JsonProperty("spanEnd") Long spanEnd, /** Concrete named targets that appear verbatim inside the span. */ + @CopilotExperimental @JsonProperty("targetMembers") List targetMembers, /** The task the permission is scoped to, when the human named one. */ + @CopilotExperimental @JsonProperty("task") String task, /** The trusted version discriminator, when one exists. Exact shell-command grants carry the byte-identical commands grounded in the human span; world-derived classes carry a file object, remote tip, or runner only when that state was captured safely. An opaque object mirroring the runtime's adjacently-tagged resolution. */ + @CopilotExperimental @JsonProperty("world") Object world ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationReadEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationReadEvent.java index 7a372823c6..b6fb8626bf 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationReadEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PermissionMessageAuthorizationReadEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -35,8 +36,10 @@ public final class PermissionMessageAuthorizationReadEvent extends SessionEvent @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionMessageAuthorizationReadEventData( /** The human turn that was read by the proposer. */ + @CopilotExperimental @JsonProperty("turnIndex") Long turnIndex, /** Whether this read activates ongoing message-backed extraction. False for a contextual-assent-only pass while auto-approval is off, so unrelated future messages remain outside extraction. */ + @CopilotExperimental @JsonProperty("activatesExtraction") Boolean activatesExtraction ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/PromptCacheBreakEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/PromptCacheBreakEvent.java index 7ad39dec97..f3a21532ba 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/PromptCacheBreakEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/PromptCacheBreakEvent.java @@ -67,6 +67,8 @@ public record PromptCacheBreakEventData( @JsonProperty("toolsRemovedRaw") List toolsRemovedRaw, /** Raw names of tools redefined since the prior call, restricted because a tool name can be user-authored */ @JsonProperty("toolsRedefinedRaw") List toolsRedefinedRaw, + /** Changed definition parts of redefined tools, as `tool:part` entries; property-level parts only for telemetry-safe tools, whose other names are hashed */ + @JsonProperty("toolsRedefinedParts") List toolsRedefinedParts, /** Whether the tool list kept its members but changed their order */ @JsonProperty("toolsReordered") Boolean toolsReordered, /** Names of the system-prompt segments whose content changed */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionAutoModeResolvedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionAutoModeResolvedEvent.java index 88b06f4471..ba26c98c2e 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionAutoModeResolvedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionAutoModeResolvedEvent.java @@ -56,6 +56,8 @@ public record SessionAutoModeResolvedEventData( @JsonProperty("fallback") Boolean fallback, /** Server-provided reason for falling back, when available */ @JsonProperty("fallbackReason") String fallbackReason, + /** Short human-readable sentence from the routing service explaining why this model was chosen, for display alongside the model. Present only when the service supplied one: it is omitted for on-device selections, when the service did not provide an explanation, and when a replayed decision made no routing call. The text is display-only and drawn from a fixed catalogue; several distinct routing categories share identical wording, so it cannot be used to recover the category or keyed on programmatically. */ + @JsonProperty("selectionReason") String selectionReason, /** Whether a sticky model choice overrode the router result */ @JsonProperty("stickyOverride") Boolean stickyOverride, /** Server-reported router processing time in milliseconds */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionFusionResolvedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionFusionResolvedEvent.java index 8bb0a5069b..44ef8be4ab 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionFusionResolvedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionFusionResolvedEvent.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; import java.util.List; import javax.annotation.processing.Generated; @@ -64,18 +65,23 @@ public record SessionFusionResolvedEventData( /** Validated orchestration pattern selected for the turn. */ @JsonProperty("pattern") FusionPattern pattern, /** Short human-readable summary of the selected workflow, suitable for immediate client display after routing. May be absent in older durable events; omit the explanation or derive one from pattern and phasePlan. Display text, not a stable machine-readable value. */ + @CopilotExperimental @JsonProperty("hint") String hint, /** Presentation-neutral phase plan for clients that render workflow progress. */ + @CopilotExperimental @JsonProperty("phasePlan") List phasePlan, /** Concrete model selected for the primary solver phase. */ @JsonProperty("primaryModel") String primaryModel, /** Concrete model selected for Critique review, or the legacy Cascade judge/repair model when role-specific fields are absent. */ @JsonProperty("secondaryModel") String secondaryModel, /** Concrete model selected for Cascade escalation-gate calls, when required. */ + @CopilotExperimental @JsonProperty("judgeModel") String judgeModel, /** Concrete model selected for Cascade repair, when required. */ + @CopilotExperimental @JsonProperty("repairModel") String repairModel, /** Planned Critique phase identities, including independent critics with repeated model IDs. May be absent in older events; consumers then use secondaryModel for the legacy critic. */ + @CopilotExperimental @JsonProperty("critics") List critics, /** Concrete model used when the planned primary model cannot execute. */ @JsonProperty("fallbackModel") String fallbackModel, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionPermissionsChangedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionPermissionsChangedEvent.java index ef332617ac..5d45858942 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionPermissionsChangedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionPermissionsChangedEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -35,10 +36,13 @@ public final class SessionPermissionsChangedEvent extends SessionEvent { @JsonInclude(JsonInclude.Include.NON_NULL) public record SessionPermissionsChangedEventData( /** Permission mode before the change */ + @CopilotExperimental @JsonProperty("previousMode") PermissionMode previousMode, /** Permission mode after the change */ + @CopilotExperimental @JsonProperty("mode") PermissionMode mode, /** Explicit LLM judge model override used by assisted mode; omitted when the provider default applies */ + @CopilotExperimental @JsonProperty("assistedApprovalModel") String assistedApprovalModel ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionShutdownEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionShutdownEvent.java index cf8600e388..ab97f6b192 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionShutdownEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionShutdownEvent.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; import java.util.Map; import javax.annotation.processing.Generated; @@ -42,6 +43,7 @@ public record SessionShutdownEventData( /** Total number of premium API requests used during the session */ @JsonProperty("totalPremiumRequests") Double totalPremiumRequests, /** Session-wide accumulated nano-AI units cost */ + @CopilotExperimental @JsonProperty("totalNanoAiu") Double totalNanoAiu, /** Session-wide per-token-type accumulated token counts */ @JsonProperty("tokenDetails") Map tokenDetails, diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java index 2b299df85b..440dc6eaaf 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; import java.util.Map; import javax.annotation.processing.Generated; @@ -42,6 +43,7 @@ public record ToolExecutionCompleteEventData( /** Model identifier that generated this tool call */ @JsonProperty("model") String model, /** FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. */ + @CopilotExperimental @JsonProperty("mcpMeta") Object mcpMeta, /** CAPI interaction ID for correlating this tool execution with upstream telemetry */ @JsonProperty("interactionId") String interactionId, @@ -62,10 +64,12 @@ public record ToolExecutionCompleteEventData( /** Whether this tool execution ran inside a sandbox container */ @JsonProperty("sandboxed") Boolean sandboxed, /** Experimental shell completion facts captured before the persisted result contents are stripped. */ + @CopilotExperimental @JsonProperty("shellExecution") ToolExecutionCompleteShellExecution shellExecution, /** Tool call ID of the parent tool invocation when this event originates from a sub-agent */ @JsonProperty("parentToolCallId") String parentToolCallId, /** Experimental HydraFusion attribution for this tool completion. */ + @CopilotExperimental @JsonProperty("fusion") FusionAttribution fusion ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionProgressEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionProgressEvent.java index 51be395194..2857bead45 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionProgressEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionProgressEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -37,7 +38,10 @@ public record ToolExecutionProgressEventData( /** Tool call ID this progress notification belongs to */ @JsonProperty("toolCallId") String toolCallId, /** Human-readable progress status message (e.g., from an MCP server) */ - @JsonProperty("progressMessage") String progressMessage + @JsonProperty("progressMessage") String progressMessage, + /** Client-only structured progress metadata. Not model-facing tool output. */ + @CopilotExperimental + @JsonProperty("structuredContent") Object structuredContent ) { } } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionStartEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionStartEvent.java index 8bd686beaf..2c0d67c3f2 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionStartEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/ToolExecutionStartEvent.java @@ -10,6 +10,7 @@ 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; /** @@ -67,6 +68,7 @@ public record ToolExecutionStartEventData( /** Tool call ID of the parent tool invocation when this event originates from a sub-agent */ @JsonProperty("parentToolCallId") String parentToolCallId, /** Experimental HydraFusion attribution for this tool execution. */ + @CopilotExperimental @JsonProperty("fusion") FusionAttribution fusion ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPrompt.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPrompt.java new file mode 100644 index 0000000000..b6458bb582 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPrompt.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * An MCP prompt descriptor. Server-provided non-standard fields are exposed under `additionalProperties`. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpPrompt( + /** The programmatic name of the prompt */ + @JsonProperty("name") String name, + /** Human-readable display title */ + @JsonProperty("title") String title, + /** Description of what this prompt provides */ + @JsonProperty("description") String description, + /** Arguments accepted by the prompt */ + @JsonProperty("arguments") List arguments, + /** Icons associated with this prompt */ + @JsonProperty("icons") List icons, + /** Prompt-level metadata */ + @JsonProperty("_meta") Map meta, + /** Server-provided non-standard descriptor fields */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptArgument.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptArgument.java new file mode 100644 index 0000000000..75ce67bbb8 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptArgument.java @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.Map; +import javax.annotation.processing.Generated; + +/** + * An argument accepted by an MCP prompt. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpPromptArgument( + /** Name of the argument */ + @JsonProperty("name") String name, + /** Description of the argument */ + @JsonProperty("description") String description, + /** Whether the argument is required; omission is distinct from false */ + @JsonProperty("required") Boolean required, + /** Argument-level metadata */ + @JsonProperty("_meta") Map meta, + /** Server-provided non-standard argument fields */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptIcon.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptIcon.java new file mode 100644 index 0000000000..0c4b7f0d4e --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptIcon.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 com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * An MCP prompt icon with standard size hints and preserved non-standard fields. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpPromptIcon( + /** Icon URI */ + @JsonProperty("src") String src, + /** Icon MIME type, when known */ + @JsonProperty("mimeType") String mimeType, + /** Icon sizes, such as `48x48` or `any` */ + @JsonProperty("sizes") List sizes, + /** Theme hint for this icon */ + @JsonProperty("theme") String theme, + /** Server-provided non-standard icon fields */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptMessage.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptMessage.java new file mode 100644 index 0000000000..785d050e93 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptMessage.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 java.util.Map; +import javax.annotation.processing.Generated; + +/** + * An MCP prompt message with opaque JSON content preserved without flattening or content-type filtering. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpPromptMessage( + /** The role of the message sender */ + @JsonProperty("role") McpPromptRole role, + /** The original MCP content block, including nested metadata and unfamiliar content types */ + @JsonProperty("content") Object content, + /** Message-level metadata */ + @JsonProperty("_meta") Map meta, + /** Server-provided non-standard message fields */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptRole.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptRole.java new file mode 100644 index 0000000000..461ebb3b73 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpPromptRole.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * The sender role of an MCP prompt message. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum McpPromptRole { + /** The {@code user} variant. */ + USER("user"), + /** The {@code assistant} variant. */ + ASSISTANT("assistant"); + + private final String value; + McpPromptRole(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static McpPromptRole fromValue(String value) { + for (McpPromptRole v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown McpPromptRole value: " + value); + } +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java index 4cb7a08312..00f74e41d2 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java @@ -33,6 +33,8 @@ public final class SessionMcpApi { public final SessionMcpAppsApi apps; /** API methods for the {@code mcp.resources} sub-namespace. */ public final SessionMcpResourcesApi resources; + /** API methods for the {@code mcp.prompts} sub-namespace. */ + public final SessionMcpPromptsApi prompts; /** @param caller the RPC transport function */ SessionMcpApi(RpcCaller caller, String sessionId) { @@ -42,6 +44,7 @@ public final class SessionMcpApi { this.headers = new SessionMcpHeadersApi(caller, sessionId); this.apps = new SessionMcpAppsApi(caller, sessionId); this.resources = new SessionMcpResourcesApi(caller, sessionId); + this.prompts = new SessionMcpPromptsApi(caller, sessionId); } /** diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthApi.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthApi.java index 835962df39..8e90316975 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthApi.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthApi.java @@ -80,7 +80,7 @@ public CompletableFuture prepareLogin(Session } /** - * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. *

* Note: the {@code sessionId} field in the params record is overridden * by the session-scoped wrapper; any value provided is ignored. @@ -96,7 +96,7 @@ public CompletableFuture login(SessionMcpOauthLoginP } /** - * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. *

* Accepts the extensible request, including inputs added after the params record. * @@ -110,6 +110,22 @@ public CompletableFuture login(SessionMcpOauthLoginR return caller.invoke("session.mcp.oauth.login", _p, SessionMcpOauthLoginResult.class); } + /** + * Host-delivered callback for a runtime-managed MCP OAuth login. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture complete(SessionMcpOauthCompleteParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.mcp.oauth.complete", _p, Void.class); + } + /** * Remote MCP server name for a passive OAuth status probe. *

diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthCompleteParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthCompleteParams.java new file mode 100644 index 0000000000..be283f0b36 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthCompleteParams.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; + +/** + * Host-delivered callback for a runtime-managed MCP OAuth login. + * + * @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 SessionMcpOauthCompleteParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** Opaque identifier returned by session.mcp.oauth.login for the pending external callback. */ + @JsonProperty("authorizationId") String authorizationId, + /** Full externally visible HTTPS callback URL received by the host, including the authorization response query parameters. Applications behind a reverse proxy must reconstruct the public URL rather than passing an internal proxy URL. */ + @JsonProperty("callbackUrl") String callbackUrl +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginParams.java index d9234d5587..6f16286adf 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginParams.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginParams.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginRequest.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginRequest.java index 90864e7dec..82219d8042 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginRequest.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginRequest.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. *

* Required inputs are constructor arguments. Optional inputs have fluent setters. * @@ -58,6 +58,10 @@ public final class SessionMcpOauthLoginRequest { @JsonProperty("grantType") private McpOauthLoginGrantType grantType; + /** Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not contain query parameters or a fragment and must be registered for the selected CIMD, DCR, or static OAuth client. */ + @JsonProperty("redirectUri") + private String redirectUri; + /** Required for owned login. Consumes the exact prepareLogin handle once. Set forceReauth and display options during preparation, not consumption. */ @JsonProperty("loginId") private String loginId; @@ -147,6 +151,15 @@ public McpOauthLoginGrantType getGrantType() { return grantType; } + /** + * Returns the {@code redirectUri} property. + * + * @return Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not contain query parameters or a fragment and must be registered for the selected CIMD, DCR, or static OAuth client. + */ + public String getRedirectUri() { + return redirectUri; + } + /** * Returns the {@code loginId} property. * @@ -242,6 +255,17 @@ public SessionMcpOauthLoginRequest setGrantType(McpOauthLoginGrantType value) { return this; } + /** + * Sets the {@code redirectUri} property. + * + * @param value Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not contain query parameters or a fragment and must be registered for the selected CIMD, DCR, or static OAuth client. + * @return this request + */ + public SessionMcpOauthLoginRequest setRedirectUri(String value) { + this.redirectUri = value; + return this; + } + /** * Sets the {@code loginId} property. * diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginResult.java index aaecf8a64a..5063ec97ca 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpOauthLoginResult.java @@ -28,7 +28,9 @@ public record SessionMcpOauthLoginResult( @JsonProperty("loginId") String loginId, /** Explicit outcome for owned sign-in. Manual callers retain their legacy response shape. */ @JsonProperty("status") McpOwnedOauthLoginStatus status, - /** URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. When present, the runtime starts the callback listener before returning and continues the flow in the background; completion is signaled via session.mcp_server_status_changed. */ - @JsonProperty("authorizationUrl") String authorizationUrl + /** URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. For the default loopback flow, the runtime starts its listener before returning. With redirectUri, the host receives the callback and completes it through session.mcp.oauth.complete. The runtime continues the flow in the background and signals completion via session.mcp_server_status_changed. */ + @JsonProperty("authorizationUrl") String authorizationUrl, + /** Opaque authorization identifier returned only for a host-managed redirect URI. The runtime also sends it as the OAuth state value, so the callback endpoint can read state and pass it with the full callback URL to session.mcp.oauth.complete. */ + @JsonProperty("authorizationId") String authorizationId ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsApi.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsApi.java new file mode 100644 index 0000000000..7b47b74a21 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsApi.java @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * 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 mcp.prompts} namespace. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class SessionMcpPromptsApi { + + private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER = RpcMapper.INSTANCE; + + private final RpcCaller caller; + private final String sessionId; + + /** @param caller the RPC transport function */ + SessionMcpPromptsApi(RpcCaller caller, String sessionId) { + this.caller = caller; + this.sessionId = sessionId; + } + + /** + * MCP server whose prompts to enumerate. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture list(SessionMcpPromptsListParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.mcp.prompts.list", _p, SessionMcpPromptsListResult.class); + } + + /** + * MCP server, prompt name, and optional string-valued arguments. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture get(SessionMcpPromptsGetParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.mcp.prompts.get", _p, SessionMcpPromptsGetResult.class); + } + +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetParams.java new file mode 100644 index 0000000000..507b55d7d7 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetParams.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 com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * MCP server, prompt name, and optional string-valued arguments. + * + * @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 SessionMcpPromptsGetParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** Name of the MCP server hosting the prompt */ + @JsonProperty("serverName") String serverName, + /** The programmatic name of the prompt */ + @JsonProperty("promptName") String promptName, + /** String-valued arguments to pass to the prompt */ + @JsonProperty("arguments") Map arguments +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetResult.java new file mode 100644 index 0000000000..26f1ee0186 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsGetResult.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * Prompt messages returned by the MCP server without sending them to the model. + * + * @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 SessionMcpPromptsGetResult( + /** Description of the prompt */ + @JsonProperty("description") String description, + /** Ordered prompt messages */ + @JsonProperty("messages") List messages, + /** MCP result metadata */ + @JsonProperty("_meta") Map meta, + /** Server-provided non-standard result fields */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListParams.java new file mode 100644 index 0000000000..e1d21eef1a --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListParams.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; + +/** + * MCP server whose prompts to enumerate. + * + * @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 SessionMcpPromptsListParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** Name of the MCP server whose prompts to enumerate */ + @JsonProperty("serverName") String serverName, + /** Opaque MCP pagination cursor from a prior `nextCursor` value */ + @JsonProperty("cursor") String cursor +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListResult.java new file mode 100644 index 0000000000..188cb5f862 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionMcpPromptsListResult.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * One page of prompts advertised by the named MCP server. + * + * @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 SessionMcpPromptsListResult( + /** Prompts advertised by the server */ + @JsonProperty("prompts") List prompts, + /** Opaque cursor for the next page, if the server has more prompts */ + @JsonProperty("nextCursor") String nextCursor, + /** MCP result metadata */ + @JsonProperty("_meta") Map meta, + /** Server-provided non-standard result fields */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellApi.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellApi.java index b96ac87ea7..ebfd1a9c7d 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellApi.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellApi.java @@ -31,7 +31,7 @@ public final class SessionShellApi { } /** - * Shell command to run, with optional working directory and timeout in milliseconds. + * Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error. *

* Note: the {@code sessionId} field in the params record is overridden * by the session-scoped wrapper; any value provided is ignored. diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecParams.java index 5ef68032d2..61e8406a6e 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecParams.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecParams.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Shell command to run, with optional working directory and timeout in milliseconds. + * Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecResult.java index 145d5cbb88..82755320db 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecResult.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionShellExecResult.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Identifier of the spawned process, used to correlate streamed output and exit notifications. + * Identifier of the spawned shell process, usable with shell.kill while the process is running. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 @@ -24,7 +24,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record SessionShellExecResult( - /** Unique identifier for tracking streamed output */ + /** Identifier usable with shell.kill while the process is running */ @JsonProperty("processId") String processId ) { } diff --git a/java/sdk/src/test/java/com/github/copilot/generated/rpc/McpPromptsTest.java b/java/sdk/src/test/java/com/github/copilot/generated/rpc/McpPromptsTest.java new file mode 100644 index 0000000000..4c0eb8050f --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/generated/rpc/McpPromptsTest.java @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.generated.rpc; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Generated dispatch and serialization supplement TypeScript's real MCP tests. + */ +class McpPromptsTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void promptResultsPreserveOpaqueContentAndOptionalFields() throws Exception { + Path directory = Path.of("").toAbsolutePath(); + Path fixture = null; + while (directory != null) { + Path candidate = directory.resolve("test/harness/mcp-prompt-fixtures.json"); + if (Files.exists(candidate)) { + fixture = candidate; + break; + } + directory = directory.getParent(); + } + assertNotNull(fixture, "Cannot find shared MCP prompt fixture"); + JsonNode fixtures = mapper.readTree(fixture.toFile()); + for (String page : List.of("firstPage", "secondPage")) { + var result = mapper.treeToValue(fixtures.get(page), SessionMcpPromptsListResult.class); + assertEquals(fixtures.get(page), mapper.valueToTree(result)); + } + var result = mapper.treeToValue(fixtures.get("richPrompt"), SessionMcpPromptsGetResult.class); + assertEquals(fixtures.get("richPrompt"), mapper.valueToTree(result)); + } + + @Test + void promptNamespaceDispatchesBoundSessionAndPreservesOptionalArguments() throws Exception { + record Call(String method, JsonNode params, Class resultType) { + } + List calls = new ArrayList<>(); + RpcCaller caller = new RpcCaller() { + @Override + public CompletableFuture invoke(String method, Object params, Class resultType) { + calls.add(new Call(method, mapper.valueToTree(params), resultType)); + return CompletableFuture.completedFuture(null); + } + }; + var session = new SessionRpc(caller, "bound-session"); + session.mcp.prompts.list(new SessionMcpPromptsListParams("foreign-session", "fixture", null)).join(); + session.mcp.prompts.list(new SessionMcpPromptsListParams("foreign-session", "fixture", "page:2/opaque+cursor=")) + .join(); + session.mcp.prompts.get(new SessionMcpPromptsGetParams("foreign-session", "fixture", "rich", null)).join(); + session.mcp.prompts.get(new SessionMcpPromptsGetParams("foreign-session", "fixture", "rich", Map.of())).join(); + session.mcp.prompts.get(new SessionMcpPromptsGetParams("foreign-session", "fixture", "rich", + Map.of("topic", "日本語", "style", ""))).join(); + + assertEquals(5, calls.size()); + for (int i = 0; i < calls.size(); i++) { + Call call = calls.get(i); + assertEquals("bound-session", call.params().get("sessionId").asText()); + assertEquals("fixture", call.params().get("serverName").asText()); + assertEquals(i < 2 ? "session.mcp.prompts.list" : "session.mcp.prompts.get", call.method()); + assertEquals(i < 2 ? SessionMcpPromptsListResult.class : SessionMcpPromptsGetResult.class, + call.resultType()); + } + assertFalse(calls.get(0).params().has("cursor")); + assertEquals("page:2/opaque+cursor=", calls.get(1).params().get("cursor").asText()); + assertEquals("rich", calls.get(2).params().get("promptName").asText()); + assertFalse(calls.get(2).params().has("arguments")); + assertEquals(mapper.readTree("{}"), calls.get(3).params().get("arguments")); + assertEquals(mapper.readTree("{\"topic\":\"日本語\",\"style\":\"\"}"), calls.get(4).params().get("arguments")); + } +} diff --git a/nodejs/package.json b/nodejs/package.json index 1a0e11a893..f9cfb9e366 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/github/copilot-sdk.git" }, "version": "0.0.0-dev", - "copilotCliVersion": "1.0.92-0", + "copilotCliVersion": "1.0.92-1", "description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC", "main": "./dist/cjs/index.js", "types": "./dist/index.d.ts", diff --git a/nodejs/src/cliVersion.ts b/nodejs/src/cliVersion.ts index 4a52758969..ba099bd89a 100644 --- a/nodejs/src/cliVersion.ts +++ b/nodejs/src/cliVersion.ts @@ -1,3 +1,3 @@ -export const COPILOT_CLI_VERSION = "1.0.92-0"; +export const COPILOT_CLI_VERSION = "1.0.92-1"; export const COPILOT_CLI_USE_NPM_PACKAGE = false; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 0efa914e67..435d18ed0c 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -3447,6 +3447,18 @@ export type McpPlanInstallResult = | CatalogUnavailableTransportError | CatalogNotInstallableError | CatalogUnavailableError; +/** + * The sender role of an MCP prompt message. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptRole". + */ +/** @experimental */ +export type McpPromptRole = + /** A message from the user. */ + | "user" + /** A message from the assistant. */ + | "assistant"; /** * MCP server configuration (stdio, remote HTTP/SSE, or in-process) * @@ -14998,6 +15010,23 @@ export interface McpOauthCancelLoginResult { */ cancelled: boolean; } +/** + * Host-delivered callback for a runtime-managed MCP OAuth login. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpOauthCompleteRequest". + */ +/** @experimental */ +export interface McpOauthCompleteRequest { + /** + * Opaque identifier returned by session.mcp.oauth.login for the pending external callback. + */ + authorizationId: string; + /** + * Full externally visible HTTPS callback URL received by the host, including the authorization response query parameters. Applications behind a reverse proxy must reconstruct the public URL rather than passing an internal proxy URL. + */ + callbackUrl: string; +} /** * Pending MCP OAuth request ID and host-provided token or cancellation response. * @@ -15026,7 +15055,7 @@ export interface McpOauthHandlePendingResult { success: boolean; } /** - * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + * Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "McpOauthLoginRequest". @@ -15062,6 +15091,10 @@ export interface McpOauthLoginRequest { */ publicClient?: boolean; grantType?: McpOauthLoginGrantType; + /** + * Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not contain query parameters or a fragment and must be registered for the selected CIMD, DCR, or static OAuth client. + */ + redirectUri?: string; /** * Required for owned login. Consumes the exact prepareLogin handle once. * Set forceReauth and display options during preparation, not consumption. @@ -15086,9 +15119,13 @@ export interface McpOauthLoginResult { loginId?: string; status?: McpOwnedOauthLoginStatus; /** - * URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. When present, the runtime starts the callback listener before returning and continues the flow in the background; completion is signaled via session.mcp_server_status_changed. + * URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. For the default loopback flow, the runtime starts its listener before returning. With redirectUri, the host receives the callback and completes it through session.mcp.oauth.complete. The runtime continues the flow in the background and signals completion via session.mcp_server_status_changed. */ authorizationUrl?: string; + /** + * Opaque authorization identifier returned only for a host-managed redirect URI. The runtime also sends it as the OAuth state value, so the callback endpoint can read state and pass it with the full callback URL to session.mcp.oauth.complete. + */ + authorizationId?: string; } /** * Effect-free preparation bound to the existing local session, requester and installation, with frozen options. @@ -15321,6 +15358,235 @@ export interface McpPrepareInstallRequest { source: McpServerCardReference; secretStorage: McpInstallationSecretStorage; } +/** + * An MCP prompt descriptor. Server-provided non-standard fields are exposed under `additionalProperties`. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPrompt". + */ +/** @experimental */ +export interface McpPrompt { + /** + * The programmatic name of the prompt + */ + name: string; + /** + * Human-readable display title + */ + title?: string; + /** + * Description of what this prompt provides + */ + description?: string; + /** + * Arguments accepted by the prompt + */ + arguments?: McpPromptArgument[]; + /** + * Icons associated with this prompt + */ + icons?: McpPromptIcon[]; + /** + * Prompt-level metadata + */ + _meta?: { + [k: string]: JsonValue | undefined; + }; + /** + * Server-provided non-standard descriptor fields + */ + additionalProperties?: { + [k: string]: JsonValue | undefined; + }; +} +/** + * An argument accepted by an MCP prompt. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptArgument". + */ +/** @experimental */ +export interface McpPromptArgument { + /** + * Name of the argument + */ + name: string; + /** + * Description of the argument + */ + description?: string; + /** + * Whether the argument is required; omission is distinct from false + */ + required?: boolean; + /** + * Argument-level metadata + */ + _meta?: { + [k: string]: JsonValue | undefined; + }; + /** + * Server-provided non-standard argument fields + */ + additionalProperties?: { + [k: string]: JsonValue | undefined; + }; +} +/** + * An MCP prompt icon with standard size hints and preserved non-standard fields. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptIcon". + */ +/** @experimental */ +export interface McpPromptIcon { + /** + * Icon URI + */ + src: string; + /** + * Icon MIME type, when known + */ + mimeType?: string; + /** + * Icon sizes, such as `48x48` or `any` + */ + sizes?: string[]; + /** + * Theme hint for this icon + */ + theme?: string; + /** + * Server-provided non-standard icon fields + */ + additionalProperties?: { + [k: string]: JsonValue | undefined; + }; +} +/** + * An MCP prompt message with opaque JSON content preserved without flattening or content-type filtering. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptMessage". + */ +/** @experimental */ +export interface McpPromptMessage { + role: McpPromptRole; + /** + * The original MCP content block, including nested metadata and unfamiliar content types + */ + content: JsonValue; + /** + * Message-level metadata + */ + _meta?: { + [k: string]: JsonValue | undefined; + }; + /** + * Server-provided non-standard message fields + */ + additionalProperties?: { + [k: string]: JsonValue | undefined; + }; +} +/** + * MCP server, prompt name, and optional string-valued arguments. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptsGetRequest". + */ +/** @experimental */ +export interface McpPromptsGetRequest { + /** + * Name of the MCP server hosting the prompt + */ + serverName: string; + /** + * The programmatic name of the prompt + */ + promptName: string; + /** + * String-valued arguments to pass to the prompt + */ + arguments?: { + [k: string]: string | undefined; + }; +} +/** + * Prompt messages returned by the MCP server without sending them to the model. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptsGetResult". + */ +/** @experimental */ +export interface McpPromptsGetResult { + /** + * Description of the prompt + */ + description?: string; + /** + * Ordered prompt messages + */ + messages: McpPromptMessage[]; + /** + * MCP result metadata + */ + _meta?: { + [k: string]: JsonValue | undefined; + }; + /** + * Server-provided non-standard result fields + */ + additionalProperties?: { + [k: string]: JsonValue | undefined; + }; +} +/** + * MCP server whose prompts to enumerate. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptsListRequest". + */ +/** @experimental */ +export interface McpPromptsListRequest { + /** + * Name of the MCP server whose prompts to enumerate + */ + serverName: string; + /** + * Opaque MCP pagination cursor from a prior `nextCursor` value + */ + cursor?: string; +} +/** + * One page of prompts advertised by the named MCP server. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpPromptsListResult". + */ +/** @experimental */ +export interface McpPromptsListResult { + /** + * Prompts advertised by the server + */ + prompts: McpPrompt[]; + /** + * Opaque cursor for the next page, if the server has more prompts + */ + nextCursor?: string; + /** + * MCP result metadata + */ + _meta?: { + [k: string]: JsonValue | undefined; + }; + /** + * Server-provided non-standard result fields + */ + additionalProperties?: { + [k: string]: JsonValue | undefined; + }; +} /** * Registration parameters for an external MCP client. * @@ -24703,7 +24969,7 @@ export interface ShellCancelUserRequestedRequest { requestId: string; } /** - * Shell command to run, with optional working directory and timeout in milliseconds. + * Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "ShellExecRequest". @@ -24724,7 +24990,7 @@ export interface ShellExecRequest { timeout?: number; } /** - * Identifier of the spawned process, used to correlate streamed output and exit notifications. + * Identifier of the spawned shell process, usable with shell.kill while the process is running. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "ShellExecResult". @@ -24732,7 +24998,7 @@ export interface ShellExecRequest { /** @experimental */ export interface ShellExecResult { /** - * Unique identifier for tracking streamed output + * Identifier usable with shell.kill while the process is running */ processId: string; } @@ -30821,7 +31087,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @experimental */ send: async (params: SendRequest): Promise => - connection.sendRequest("session.send", { sessionId, ...params }), + connection.sendRequest("session.send", { ...params, sessionId }), /** * Sends zero or more user messages to the session in a single turn and returns their message IDs. All provided messages are appended to the conversation in order, then exactly one agent turn runs over the resulting history. When the list is empty, one turn runs over the existing history with no new user message. Remote-backed (Mission Control) sessions do not support this method and will return an error. * @@ -30832,7 +31098,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @experimental */ sendMessages: async (params: SendMessagesRequest): Promise => - connection.sendRequest("session.sendMessages", { sessionId, ...params }), + connection.sendRequest("session.sendMessages", { ...params, sessionId }), /** @experimental */ sandbox: { /** @@ -30850,7 +31116,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of attempting to disable sandboxing for the current session. */ disableForSession: async (params: SandboxDisableForSessionRequest): Promise => - connection.sendRequest("session.sandbox.disableForSession", { sessionId, ...params }), + connection.sendRequest("session.sandbox.disableForSession", { ...params, sessionId }), /** * Adds the path offered by a pending sandbox escalation permission request's sandboxPathGrant to the session's sandbox policy and approves the request, so the blocked operation re-runs inside the sandbox rather than outside it. The request is rejected unless the exact request is still pending, carries a sandboxPathGrant, and the grant still takes effect under the current managed policy. Does not persist the path; hosts that store sandbox settings save it themselves. * @@ -30859,7 +31125,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of accepting a sandbox path grant. */ grantPathForRequest: async (params: SandboxGrantPathForRequestRequest): Promise => - connection.sendRequest("session.sandbox.grantPathForRequest", { sessionId, ...params }), + connection.sendRequest("session.sandbox.grantPathForRequest", { ...params, sessionId }), }, /** * Aborts the current agent turn. @@ -30871,7 +31137,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @experimental */ abort: async (params: AbortRequest): Promise => - connection.sendRequest("session.abort", { sessionId, ...params }), + connection.sendRequest("session.abort", { ...params, sessionId }), /** * Interrupts the current main agent turn while leaving running background work (subagents, sidekicks, and promoted attached shells) alive. No-op when the main loop is not processing. * @@ -30882,7 +31148,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @experimental */ interruptMainTurn: async (params: InterruptMainTurnRequest): Promise => - connection.sendRequest("session.interruptMainTurn", { sessionId, ...params }), + connection.sendRequest("session.interruptMainTurn", { ...params, sessionId }), /** * Cancels every running background agent (task-registry subagents plus sidekick agents) without interrupting the main agent loop. Promoted attached shells are left running. * @@ -30900,7 +31166,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @experimental */ shutdown: async (params: ShutdownRequest): Promise => - connection.sendRequest("session.shutdown", { sessionId, ...params }), + connection.sendRequest("session.shutdown", { ...params, sessionId }), /** @experimental */ gitHubAuth: { /** @@ -30918,7 +31184,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the credential update succeeded. */ setCredentials: async (params: SessionSetCredentialsParams): Promise => - connection.sendRequest("session.gitHubAuth.setCredentials", { sessionId, ...params }), + connection.sendRequest("session.gitHubAuth.setCredentials", { ...params, sessionId }), }, /** @experimental */ accounts: { @@ -30930,7 +31196,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The enumerated collection, keyed by the same selector as the query. */ enumerate: async (params: AccountsEnumerateRequest): Promise => - connection.sendRequest("session.accounts.enumerate", { sessionId, ...params }), + connection.sendRequest("session.accounts.enumerate", { ...params, sessionId }), /** * Read one typed accounts datum: the active account, a neutral status summary, or the last authentication errors. * @@ -30939,7 +31205,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The read result, keyed by the same selector as the query. */ get: async (params: AccountsGetRequest): Promise => - connection.sendRequest("session.accounts.get", { sessionId, ...params }), + connection.sendRequest("session.accounts.get", { ...params, sessionId }), /** * Apply one non-interactive accounts mutation: switch the active account, log an account out, or set credentials from a token. * @@ -30948,7 +31214,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of a non-interactive accounts mutation. */ set: async (params: AccountsSetRequest): Promise => - connection.sendRequest("session.accounts.set", { sessionId, ...params }), + connection.sendRequest("session.accounts.set", { ...params, sessionId }), /** @experimental */ login: { /** @@ -30959,7 +31225,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns A started login flow: its opaque id and first step. */ begin: async (params: AuthLoginBeginRequest): Promise => - connection.sendRequest("session.accounts.login.begin", { sessionId, ...params }), + connection.sendRequest("session.accounts.login.begin", { ...params, sessionId }), /** * Advance an in-flight login flow, optionally fulfilling an input-required step, and return the next step. * @@ -30968,14 +31234,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns One step in an interactive login flow. The consumer acts on the step and calls advance to proceed. Browser-open is encoded as two distinct steps by design: `open-url` is CONSUMER-driven (the provider surfaces the authorize URL and the consumer opens it — github.com/GHEC web), while `needs-interaction` is PROVIDER-driven (the provider opens the browser or broker UI itself and does not surface a URL — Entra). */ advance: async (params: AuthLoginAdvanceRequest): Promise => - connection.sendRequest("session.accounts.login.advance", { sessionId, ...params }), + connection.sendRequest("session.accounts.login.advance", { ...params, sessionId }), /** * Cancel an in-flight login flow and release its resources. * * @param params Cancel an in-flight login flow. */ cancel: async (params: AuthLoginCancelRequest): Promise => - connection.sendRequest("session.accounts.login.cancel", { sessionId, ...params }), + connection.sendRequest("session.accounts.login.cancel", { ...params, sessionId }), }, }, /** @experimental */ @@ -30988,7 +31254,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of collecting a session debug bundle. */ collectLogs: async (params: DebugCollectLogsRequest): Promise => - connection.sendRequest("session.debug.collectLogs", { sessionId, ...params }), + connection.sendRequest("session.debug.collectLogs", { ...params, sessionId }), }, /** @experimental */ canvas: { @@ -31014,14 +31280,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Open canvas instance snapshot. */ open: async (params: CanvasOpenRequest): Promise => - connection.sendRequest("session.canvas.open", { sessionId, ...params }), + connection.sendRequest("session.canvas.open", { ...params, sessionId }), /** * Closes an open canvas instance. * * @param params Canvas close parameters. */ close: async (params: CanvasCloseRequest): Promise => - connection.sendRequest("session.canvas.close", { sessionId, ...params }), + connection.sendRequest("session.canvas.close", { ...params, sessionId }), /** @experimental */ action: { /** @@ -31032,7 +31298,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Canvas action invocation result. */ invoke: async (params: CanvasActionInvokeRequest): Promise => - connection.sendRequest("session.canvas.action.invoke", { sessionId, ...params }), + connection.sendRequest("session.canvas.action.invoke", { ...params, sessionId }), }, }, /** @experimental */ @@ -31045,7 +31311,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Complete current or terminal workflow run envelope. */ run: async (params: WorkflowRunRequest): Promise => - connection.sendRequest("session.workflow.run", { sessionId, ...params }), + connection.sendRequest("session.workflow.run", { ...params, sessionId }), /** * Resumes a dynamic workflow run using its persisted name, arguments, journal, and accounting. * @@ -31054,7 +31320,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Resolved persisted workflow identity and resumed run envelope. */ resume: async (params: WorkflowResumeRequest): Promise => - connection.sendRequest("session.workflow.resume", { sessionId, ...params }), + connection.sendRequest("session.workflow.resume", { ...params, sessionId }), /** * Gets the current or settled envelope for a dynamic workflow run. * @@ -31063,7 +31329,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Complete current or terminal workflow run envelope. */ getRun: async (params: WorkflowGetRunRequest): Promise => - connection.sendRequest("session.workflow.getRun", { sessionId, ...params }), + connection.sendRequest("session.workflow.getRun", { ...params, sessionId }), /** * Lists durable dynamic workflow runs for this session in creation order. * @@ -31072,7 +31338,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns A page of workflow runs in durable creation order. */ listRuns: async (params: WorkflowListRunsRequest): Promise => - connection.sendRequest("session.workflow.listRuns", { sessionId, ...params }), + connection.sendRequest("session.workflow.listRuns", { ...params, sessionId }), /** * Gets durable and live observability detail for one dynamic workflow run. * @@ -31081,7 +31347,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Full workflow run observability detail. */ getRunDetail: async (params: WorkflowGetRunRequest): Promise => - connection.sendRequest("session.workflow.getRunDetail", { sessionId, ...params }), + connection.sendRequest("session.workflow.getRunDetail", { ...params, sessionId }), /** * Pages durable progress for one dynamic workflow run. * @@ -31090,7 +31356,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns A bidirectional page of workflow progress. */ getRunProgress: async (params: WorkflowGetRunProgressRequest): Promise => - connection.sendRequest("session.workflow.getRunProgress", { sessionId, ...params }), + connection.sendRequest("session.workflow.getRunProgress", { ...params, sessionId }), /** * Requests cancellation of a dynamic workflow run and returns its run envelope. * @@ -31099,7 +31365,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Complete current or terminal workflow run envelope. */ cancel: async (params: WorkflowCancelRequest): Promise => - connection.sendRequest("session.workflow.cancel", { sessionId, ...params }), + connection.sendRequest("session.workflow.cancel", { ...params, sessionId }), /** * Pauses a running dynamic workflow and returns its settled run envelope. * @@ -31108,7 +31374,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Complete current or terminal workflow run envelope. */ pause: async (params: WorkflowPauseRequest): Promise => - connection.sendRequest("session.workflow.pause", { sessionId, ...params }), + connection.sendRequest("session.workflow.pause", { ...params, sessionId }), /** * Records a batch of ordered dynamic workflow progress lines. * @@ -31117,7 +31383,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Acknowledgement that a workflow request was accepted. */ log: async (params: WorkflowLogRequest): Promise => - connection.sendRequest("session.workflow.log", { sessionId, ...params }), + connection.sendRequest("session.workflow.log", { ...params, sessionId }), /** * Runs one dynamic-workflow-scoped subagent and returns its result. * @@ -31126,7 +31392,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of one workflow-scoped subagent call. */ agent: async (params: WorkflowAgentRequest): Promise => - connection.sendRequest("session.workflow.agent", { sessionId, ...params }), + connection.sendRequest("session.workflow.agent", { ...params, sessionId }), /** @experimental */ journal: { /** @@ -31137,7 +31403,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of reading a workflow journal entry. */ get: async (params: WorkflowJournalGetRequest): Promise => - connection.sendRequest("session.workflow.journal.get", { sessionId, ...params }), + connection.sendRequest("session.workflow.journal.get", { ...params, sessionId }), /** * Stores a memoized dynamic workflow journal entry. * @@ -31146,7 +31412,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Acknowledgement that a workflow request was accepted. */ put: async (params: WorkflowJournalPutRequest): Promise => - connection.sendRequest("session.workflow.journal.put", { sessionId, ...params }), + connection.sendRequest("session.workflow.journal.put", { ...params, sessionId }), }, }, /** @experimental */ @@ -31166,7 +31432,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The model identifier active on the session after the switch. */ switchTo: async (params: ModelSwitchToRequest): Promise => - connection.sendRequest("session.model.switchTo", { sessionId, ...params }), + connection.sendRequest("session.model.switchTo", { ...params, sessionId }), /** * Requests an Auto preference change without changing the session's selected model. The latest unclaimed request wins; the runtime commits it only after a later prompt using the `auto` model mints a usable model and token pair. A `pending` response confirms that the request was accepted, not that it committed. Observe eventual success through `session.model_change`, failure through the ephemeral `session.auto_tier_switch_failed` event, or current unclaimed state through `session.model.getCurrent`. * @@ -31175,7 +31441,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Immediate acknowledgement and Auto preference snapshot after a switch request. This result never implies that a pending preference committed. */ switchAutoTier: async (params: ModelSwitchAutoTierRequest): Promise => - connection.sendRequest("session.model.switchAutoTier", { sessionId, ...params }), + connection.sendRequest("session.model.switchAutoTier", { ...params, sessionId }), /** * Replaces or clears the host-supplied model allowlist for a running session. * @@ -31184,7 +31450,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The applied host allowlist and effective session model policy after intersection. */ setAllowedModels: async (params: ModelSetAllowedModelsRequest): Promise => - connection.sendRequest("session.model.setAllowedModels", { sessionId, ...params }), + connection.sendRequest("session.model.setAllowedModels", { ...params, sessionId }), /** * Updates the session's reasoning effort without changing the selected model. * @@ -31193,7 +31459,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Update the session's reasoning effort without changing the selected model. Use `switchTo` instead when you also need to change the model. The runtime stores the effort on the session and applies it to subsequent turns. */ setReasoningEffort: async (params: ModelSetReasoningEffortRequest): Promise => - connection.sendRequest("session.model.setReasoningEffort", { sessionId, ...params }), + connection.sendRequest("session.model.setReasoningEffort", { ...params, sessionId }), /** * Lists models available to this session using its own auth and integration context. Connected hosts (CLI TUI, GitHub App) should call this through the session client so remote sessions return the remote CLI's available models rather than the caller's. * @@ -31202,7 +31468,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The list of models available to this session. */ list: async (params?: SessionModelListRequest): Promise => - connection.sendRequest("session.model.list", { sessionId, ...params }), + connection.sendRequest("session.model.list", { ...params, sessionId }), }, /** @experimental */ mode: { @@ -31221,7 +31487,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Outcome of a session mode change, including any model switch it triggered and follow-up the host must perform. */ set: async (params: ModeSetRequest): Promise => - connection.sendRequest("session.mode.set", { sessionId, ...params }), + connection.sendRequest("session.mode.set", { ...params, sessionId }), }, /** @experimental */ name: { @@ -31238,7 +31504,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params New friendly name to apply to the session. */ set: async (params: NameSetRequest): Promise => - connection.sendRequest("session.name.set", { sessionId, ...params }), + connection.sendRequest("session.name.set", { ...params, sessionId }), /** * Persists an auto-generated session summary as the session's name when no user-set name exists. * @@ -31247,7 +31513,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the auto-generated summary was applied as the session's name. */ setAuto: async (params: NameSetAutoRequest): Promise => - connection.sendRequest("session.name.setAuto", { sessionId, ...params }), + connection.sendRequest("session.name.setAuto", { ...params, sessionId }), }, /** @experimental */ plan: { @@ -31264,7 +31530,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Replacement contents to write to the session plan file. */ update: async (params: PlanUpdateRequest): Promise => - connection.sendRequest("session.plan.update", { sessionId, ...params }), + connection.sendRequest("session.plan.update", { ...params, sessionId }), /** * Deletes the session plan file from the workspace. */ @@ -31302,7 +31568,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Current workspace metadata for the session, including its absolute filesystem path when available. */ updateMetadata: async (params: WorkspacesUpdateMetadataRequest): Promise => - connection.sendRequest("session.workspaces.updateMetadata", { sessionId, ...params }), + connection.sendRequest("session.workspaces.updateMetadata", { ...params, sessionId }), /** * Ensures a local session workspace exists and returns it. * @@ -31311,7 +31577,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Current workspace metadata for the session, including its absolute filesystem path when available. */ ensure: async (params: WorkspacesEnsureRequest): Promise => - connection.sendRequest("session.workspaces.ensure", { sessionId, ...params }), + connection.sendRequest("session.workspaces.ensure", { ...params, sessionId }), /** * Lists files stored in the session workspace files directory. * @@ -31327,14 +31593,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Contents of the requested workspace file as a UTF-8 string. */ readFile: async (params: WorkspacesReadFileRequest): Promise => - connection.sendRequest("session.workspaces.readFile", { sessionId, ...params }), + connection.sendRequest("session.workspaces.readFile", { ...params, sessionId }), /** * Creates or overwrites a file in the session workspace files directory. * * @param params Relative path and UTF-8 content for the workspace file to create or overwrite. */ createFile: async (params: WorkspacesCreateFileRequest): Promise => - connection.sendRequest("session.workspaces.createFile", { sessionId, ...params }), + connection.sendRequest("session.workspaces.createFile", { ...params, sessionId }), /** * Returns metadata for a file or directory in the session workspace files directory. * @@ -31343,28 +31609,28 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Filesystem metadata for a path in the session workspace files directory. */ statFile: async (params: WorkspacesStatFileRequest): Promise => - connection.sendRequest("session.workspaces.statFile", { sessionId, ...params }), + connection.sendRequest("session.workspaces.statFile", { ...params, sessionId }), /** * Creates a directory in the session workspace files directory. * * @param params Directory to create within the session workspace files directory. */ createDirectory: async (params: WorkspacesCreateDirectoryRequest): Promise => - connection.sendRequest("session.workspaces.createDirectory", { sessionId, ...params }), + connection.sendRequest("session.workspaces.createDirectory", { ...params, sessionId }), /** * Removes a file or directory from the session workspace files directory. * * @param params File or directory to remove from the session workspace files directory. */ removePath: async (params: WorkspacesRemovePathRequest): Promise => - connection.sendRequest("session.workspaces.removePath", { sessionId, ...params }), + connection.sendRequest("session.workspaces.removePath", { ...params, sessionId }), /** * Renames a file or directory within the session workspace files directory. * * @param params Source and destination paths for a rename within the session workspace files directory. */ renamePath: async (params: WorkspacesRenamePathRequest): Promise => - connection.sendRequest("session.workspaces.renamePath", { sessionId, ...params }), + connection.sendRequest("session.workspaces.renamePath", { ...params, sessionId }), /** * Lists workspace checkpoints in chronological order. * @@ -31380,7 +31646,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Checkpoint content as a UTF-8 string, or null when the checkpoint or workspace is missing. */ readCheckpoint: async (params: WorkspacesReadCheckpointRequest): Promise => - connection.sendRequest("session.workspaces.readCheckpoint", { sessionId, ...params }), + connection.sendRequest("session.workspaces.readCheckpoint", { ...params, sessionId }), /** * Adds a compaction summary checkpoint to the local session workspace. * @@ -31389,7 +31655,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Persisted summary metadata and refreshed workspace metadata. */ addSummary: async (params: WorkspacesAddSummaryRequest): Promise => - connection.sendRequest("session.workspaces.addSummary", { sessionId, ...params }), + connection.sendRequest("session.workspaces.addSummary", { ...params, sessionId }), /** * Truncates local workspace compaction summaries after a rollback. * @@ -31398,7 +31664,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Current workspace metadata for the session, including its absolute filesystem path when available. */ truncateSummaries: async (params: WorkspacesTruncateSummariesRequest): Promise => - connection.sendRequest("session.workspaces.truncateSummaries", { sessionId, ...params }), + connection.sendRequest("session.workspaces.truncateSummaries", { ...params, sessionId }), /** * Reads the autopilot objective state file from the local session workspace. * @@ -31414,7 +31680,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of writing the autopilot objective file. */ writeAutopilotObjective: async (params: WorkspacesWriteAutopilotObjectiveRequest): Promise => - connection.sendRequest("session.workspaces.writeAutopilotObjective", { sessionId, ...params }), + connection.sendRequest("session.workspaces.writeAutopilotObjective", { ...params, sessionId }), /** * Deletes the autopilot objective state file from the local session workspace. * @@ -31437,7 +31703,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Descriptor for the saved paste file, or null when the workspace is unavailable. */ saveLargePaste: async (params: WorkspacesSaveLargePasteRequest): Promise => - connection.sendRequest("session.workspaces.saveLargePaste", { sessionId, ...params }), + connection.sendRequest("session.workspaces.saveLargePaste", { ...params, sessionId }), /** * Computes a diff for the session workspace. Never rejects for a busy session: a `session`-mode diff that cannot read the session's file-change captures falls back to an unstaged git diff with `isFallback: true` and reports why in `unavailableReason`. * @@ -31446,7 +31712,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Workspace diff result for the requested mode. */ diff: async (params: WorkspacesDiffRequest): Promise => - connection.sendRequest("session.workspaces.diff", { sessionId, ...params }), + connection.sendRequest("session.workspaces.diff", { ...params, sessionId }), }, /** @experimental */ autopilotObjective: { @@ -31475,7 +31741,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Host-driven completion items for the current composer input. Empty when the host returns no items or does not support completions. */ request: async (params: CompletionsRequestRequest): Promise => - connection.sendRequest("session.completions.request", { sessionId, ...params }), + connection.sendRequest("session.completions.request", { ...params, sessionId }), }, /** @experimental */ instructions: { @@ -31512,7 +31778,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether fleet mode was successfully activated. */ start: async (params: FleetStartRequest): Promise => - connection.sendRequest("session.fleet.start", { sessionId, ...params }), + connection.sendRequest("session.fleet.start", { ...params, sessionId }), }, /** @experimental */ agent: { @@ -31524,14 +31790,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Agents available to the session. */ list: async (params?: SessionAgentListRequest): Promise => - connection.sendRequest("session.agent.list", { sessionId, ...params }), + connection.sendRequest("session.agent.list", { ...params, sessionId }), /** * Sets an in-memory authored prompt override for an available agent. For built-in agents, this replaces only the static base prompt while preserving runtime-owned dynamic prompt composition and behavior. The special `general-purpose` agent is not overrideable. Overrides are not persisted; resumed and forked sessions start without them, so the host must re-apply them. * * @param params An in-memory authored prompt override for an available agent. */ setPrompt: async (params: AgentSetPromptRequest): Promise => - connection.sendRequest("session.agent.setPrompt", { sessionId, ...params }), + connection.sendRequest("session.agent.setPrompt", { ...params, sessionId }), /** * Gets the currently selected custom agent for the session. * @@ -31547,7 +31813,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The newly selected custom agent. */ select: async (params: AgentSelectRequest): Promise => - connection.sendRequest("session.agent.select", { sessionId, ...params }), + connection.sendRequest("session.agent.select", { ...params, sessionId }), /** * Clears the selected custom agent and returns the session to the default agent. */ @@ -31571,7 +31837,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Identifier assigned to the newly started background agent task. */ startAgent: async (params: TasksStartAgentRequest): Promise => - connection.sendRequest("session.tasks.startAgent", { sessionId, ...params }), + connection.sendRequest("session.tasks.startAgent", { ...params, sessionId }), /** * Lists background tasks tracked by the session. * @@ -31587,7 +31853,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of registering or reclaiming a client-owned task. */ register: async (params: TasksRegisterRequest): Promise => - connection.sendRequest("session.tasks.register", { sessionId, ...params }), + connection.sendRequest("session.tasks.register", { ...params, sessionId }), /** * Publishes generic progress or a terminal outcome for a client-owned task. * @@ -31596,7 +31862,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of publishing a client-owned task update. */ update: async (params: TasksUpdateRequest): Promise => - connection.sendRequest("session.tasks.update", { sessionId, ...params }), + connection.sendRequest("session.tasks.update", { ...params, sessionId }), /** * Refreshes metadata for any detached background shells the runtime knows about. * @@ -31619,7 +31885,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Progress information for the task, or null when no task with that ID is tracked. */ getProgress: async (params: TasksGetProgressRequest): Promise => - connection.sendRequest("session.tasks.getProgress", { sessionId, ...params }), + connection.sendRequest("session.tasks.getProgress", { ...params, sessionId }), /** * Returns the first sync-waiting task that can currently be promoted to background mode. * @@ -31635,7 +31901,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the task was successfully promoted to background mode. */ promoteToBackground: async (params: TasksPromoteToBackgroundRequest): Promise => - connection.sendRequest("session.tasks.promoteToBackground", { sessionId, ...params }), + connection.sendRequest("session.tasks.promoteToBackground", { ...params, sessionId }), /** * Atomically promotes the first promotable sync-waiting task to background mode and returns it. * @@ -31651,7 +31917,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the background task was successfully cancelled. */ cancel: async (params: TasksCancelRequest): Promise => - connection.sendRequest("session.tasks.cancel", { sessionId, ...params }), + connection.sendRequest("session.tasks.cancel", { ...params, sessionId }), /** * Removes a completed or cancelled background task from tracking. * @@ -31660,7 +31926,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the task was removed. False when the task does not exist or is still running/idle. */ remove: async (params: TasksRemoveRequest): Promise => - connection.sendRequest("session.tasks.remove", { sessionId, ...params }), + connection.sendRequest("session.tasks.remove", { ...params, sessionId }), /** * Sends a message to a background agent task. * @@ -31669,7 +31935,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the message was delivered, with an error message when delivery failed. */ sendMessage: async (params: TasksSendMessageRequest): Promise => - connection.sendRequest("session.tasks.sendMessage", { sessionId, ...params }), + connection.sendRequest("session.tasks.sendMessage", { ...params, sessionId }), }, /** @experimental */ skills: { @@ -31693,14 +31959,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Name of the skill to enable for the session. */ enable: async (params: SkillsEnableRequest): Promise => - connection.sendRequest("session.skills.enable", { sessionId, ...params }), + connection.sendRequest("session.skills.enable", { ...params, sessionId }), /** * Disables a skill for the session. * * @param params Name of the skill to disable for the session. */ disable: async (params: SkillsDisableRequest): Promise => - connection.sendRequest("session.skills.disable", { sessionId, ...params }), + connection.sendRequest("session.skills.disable", { ...params, sessionId }), /** * Reloads skill definitions for the session. * @@ -31731,21 +31997,21 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Tools exposed by the connected MCP server. Throws when the server is not connected. */ listTools: async (params: McpListToolsRequest): Promise => - connection.sendRequest("session.mcp.listTools", { sessionId, ...params }), + connection.sendRequest("session.mcp.listTools", { ...params, sessionId }), /** * Enables an MCP server for the session. * * @param params Name of the MCP server to enable for the session. */ enable: async (params: McpEnableRequest): Promise => - connection.sendRequest("session.mcp.enable", { sessionId, ...params }), + connection.sendRequest("session.mcp.enable", { ...params, sessionId }), /** * Disables an MCP server for the session. * * @param params Name of the MCP server to disable for the session. */ disable: async (params: McpDisableRequest): Promise => - connection.sendRequest("session.mcp.disable", { sessionId, ...params }), + connection.sendRequest("session.mcp.disable", { ...params, sessionId }), /** * Reloads MCP server connections for the session. */ @@ -31766,7 +32032,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Outcome of an MCP sampling execution: success result, failure error, or cancellation. */ executeSampling: async (params: McpExecuteSamplingParams): Promise => - connection.sendRequest("session.mcp.executeSampling", { sessionId, ...params }), + connection.sendRequest("session.mcp.executeSampling", { ...params, sessionId }), /** * Cancels an in-flight MCP sampling execution by request ID. * @@ -31775,7 +32041,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether an in-flight sampling execution with the given requestId was found and cancelled. */ cancelSamplingExecution: async (params: McpCancelSamplingExecutionParams): Promise => - connection.sendRequest("session.mcp.cancelSamplingExecution", { sessionId, ...params }), + connection.sendRequest("session.mcp.cancelSamplingExecution", { ...params, sessionId }), /** * Sets how environment-variable values supplied to MCP servers are resolved (direct or indirect). * @@ -31784,7 +32050,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Env-value mode recorded on the session after the update. */ setEnvValueMode: async (params: McpSetEnvValueModeParams): Promise => - connection.sendRequest("session.mcp.setEnvValueMode", { sessionId, ...params }), + connection.sendRequest("session.mcp.setEnvValueMode", { ...params, sessionId }), /** * Removes the auto-managed `github` MCP server when present. * @@ -31798,21 +32064,21 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Server name and optional configuration for an individual MCP server start. Omit `config` for a config-free start-by-name of an already-configured server. */ startServer: async (params: McpStartServerRequest): Promise => - connection.sendRequest("session.mcp.startServer", { sessionId, ...params }), + connection.sendRequest("session.mcp.startServer", { ...params, sessionId }), /** * Restarts an individual MCP server on the live session (stops then starts). Omit `config` for a config-free restart-by-name of an already-configured server; supply `config` to restart with a replacement configuration. Session-scoped and ephemeral: does NOT modify persistent user configuration (`mcp.config.*`). * * @param params Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. */ restartServer: async (params: McpRestartServerRequest): Promise => - connection.sendRequest("session.mcp.restartServer", { sessionId, ...params }), + connection.sendRequest("session.mcp.restartServer", { ...params, sessionId }), /** * Stops an individual MCP server on the session's host. * * @param params Server name for an individual MCP server stop. */ stopServer: async (params: McpStopServerRequest): Promise => - connection.sendRequest("session.mcp.stopServer", { sessionId, ...params }), + connection.sendRequest("session.mcp.stopServer", { ...params, sessionId }), /** * Checks whether a named MCP server is currently running on the session's host. * @@ -31821,7 +32087,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Whether the named MCP server is running. */ isServerRunning: async (params: McpIsServerRunningRequest): Promise => - connection.sendRequest("session.mcp.isServerRunning", { sessionId, ...params }), + connection.sendRequest("session.mcp.isServerRunning", { ...params, sessionId }), /** @experimental */ oauth: { /** @@ -31832,14 +32098,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending MCP OAuth response was accepted. */ handlePendingRequest: async (params: McpOauthHandlePendingRequest): Promise => - connection.sendRequest("session.mcp.oauth.handlePendingRequest", { sessionId, ...params }), + connection.sendRequest("session.mcp.oauth.handlePendingRequest", { ...params, sessionId }), /** * Notifies the session that MCP OAuth authentication succeeded and updated credentials were persisted, so cached tool definitions can be refreshed. * * @param params Identifies the MCP server whose persisted OAuth credentials were updated. */ authenticationStateChanged: async (params: McpOauthAuthenticationStateChangedRequest): Promise => - connection.sendRequest("session.mcp.oauth.authenticationStateChanged", { sessionId, ...params }), + connection.sendRequest("session.mcp.oauth.authenticationStateChanged", { ...params, sessionId }), /** * Prepares an inert, expiring owned OAuth login bound to the original session requester and exact installation. Does not activate, connect, read credentials or open a browser. * @@ -31848,16 +32114,23 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns An inert runtime-issued login handle. Preparation alone performs no activation or OAuth work. */ prepareLogin: async (params: SessionMcpOauthPrepareLoginRequest): Promise => - connection.sendRequest("session.mcp.oauth.prepareLogin", { sessionId, ...params }), + connection.sendRequest("session.mcp.oauth.prepareLogin", { ...params, sessionId }), /** * Starts OAuth authentication for a remote MCP server. Owned servers require the original one-use prepareLogin handle and exact installation ID; manual servers retain the existing direct login behaviour. * - * @param params Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + * @param params Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. * * @returns OAuth authorization URL the caller should open, or empty when cached tokens already authenticated the server. */ login: async (params: McpOauthLoginRequest): Promise => - connection.sendRequest("session.mcp.oauth.login", { sessionId, ...params }), + connection.sendRequest("session.mcp.oauth.login", { ...params, sessionId }), + /** + * Completes a runtime-managed MCP OAuth login after the authorization server redirects to a host-managed callback URL. + * + * @param params Host-delivered callback for a runtime-managed MCP OAuth login. + */ + complete: async (params: McpOauthCompleteRequest): Promise => + connection.sendRequest("session.mcp.oauth.complete", { ...params, sessionId }), /** * Passively probes a configured remote MCP server to classify whether OAuth is required or a cached/override token is accepted. Does not start OAuth, emit pending OAuth requests, or mutate MCP connection state. * @@ -31866,7 +32139,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Passive MCP OAuth probe result. `authenticated` means the server accepted the probe request while an OAuth-origin access token was attached; it does not prove the server required or independently validated that token. The probe does not make a second unauthenticated request. Failed is an expected probe-domain outcome; JSON-RPC errors are reserved for API-call failures. */ probe: async (params: McpOauthProbeRequest): Promise => - connection.sendRequest("session.mcp.oauth.probe", { sessionId, ...params }), + connection.sendRequest("session.mcp.oauth.probe", { ...params, sessionId }), /** * Cancels the exact owned OAuth login issued to this original session requester, without clearing shared credentials. * @@ -31875,7 +32148,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Honest terminal cancellation result; persistence or recovery failures remain RPC errors. */ cancelLogin: async (params: SessionMcpOauthCancelLoginRequest): Promise => - connection.sendRequest("session.mcp.oauth.cancelLogin", { sessionId, ...params }), + connection.sendRequest("session.mcp.oauth.cancelLogin", { ...params, sessionId }), /** * Responds to a pending MCP OAuth authorization request by its request id. * @@ -31884,7 +32157,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending MCP OAuth response was accepted. */ respond: async (params: McpOauthRespondRequest): Promise => - connection.sendRequest("session.mcp.oauth.respond", { sessionId, ...params }), + connection.sendRequest("session.mcp.oauth.respond", { ...params, sessionId }), }, /** @experimental */ headers: { @@ -31896,7 +32169,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending MCP headers refresh response was accepted. */ handlePendingHeadersRefreshRequest: async (params: McpHeadersHandlePendingHeadersRefreshRequestRequest): Promise => - connection.sendRequest("session.mcp.headers.handlePendingHeadersRefreshRequest", { sessionId, ...params }), + connection.sendRequest("session.mcp.headers.handlePendingHeadersRefreshRequest", { ...params, sessionId }), }, /** @experimental */ apps: { @@ -31908,7 +32181,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Resource contents returned by the MCP server. */ readResource: async (params: McpAppsReadResourceRequest): Promise => - connection.sendRequest("session.mcp.apps.readResource", { sessionId, ...params }), + connection.sendRequest("session.mcp.apps.readResource", { ...params, sessionId }), /** * List tools that an MCP App view is allowed to call (SEP-1865 visibility filter). Returns tools whose `_meta.ui.visibility` is unset (default `["model","app"]`) or includes `"app"`. * @@ -31917,7 +32190,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns App-callable tools from the named MCP server. */ listTools: async (params: McpAppsListToolsRequest): Promise => - connection.sendRequest("session.mcp.apps.listTools", { sessionId, ...params }), + connection.sendRequest("session.mcp.apps.listTools", { ...params, sessionId }), /** * Call an MCP tool from an MCP App view (SEP-1865). Enforces the visibility check that prevents an app iframe from invoking model-only tools. Returns the standard MCP `CallToolResult`. * @@ -31926,14 +32199,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Standard MCP CallToolResult */ callTool: async (params: McpAppsCallToolRequest): Promise => - connection.sendRequest("session.mcp.apps.callTool", { sessionId, ...params }), + connection.sendRequest("session.mcp.apps.callTool", { ...params, sessionId }), /** * Replace the host context returned to MCP App guests on `ui/initialize`. Hosts use this to advertise theme, locale, or other metadata to the guest UI. * * @param params Host context to advertise to MCP App guests. */ setHostContext: async (params: McpAppsSetHostContextRequest): Promise => - connection.sendRequest("session.mcp.apps.setHostContext", { sessionId, ...params }), + connection.sendRequest("session.mcp.apps.setHostContext", { ...params, sessionId }), /** * Read the current host context advertised to MCP App guests. * @@ -31949,7 +32222,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Diagnostic snapshot of MCP Apps wiring for the named server. */ diagnose: async (params: McpAppsDiagnoseRequest): Promise => - connection.sendRequest("session.mcp.apps.diagnose", { sessionId, ...params }), + connection.sendRequest("session.mcp.apps.diagnose", { ...params, sessionId }), }, /** @experimental */ resources: { @@ -31961,7 +32234,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Resource contents returned by the MCP server. */ read: async (params: McpResourcesReadRequest): Promise => - connection.sendRequest("session.mcp.resources.read", { sessionId, ...params }), + connection.sendRequest("session.mcp.resources.read", { ...params, sessionId }), /** * Enumerate one page of resources a connected MCP server exposes (proxies MCP `resources/list`). Pass `cursor` to continue from a prior result's `nextCursor`. * @@ -31970,7 +32243,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns One page of resources advertised by the named MCP server. */ list: async (params: McpResourcesListRequest): Promise => - connection.sendRequest("session.mcp.resources.list", { sessionId, ...params }), + connection.sendRequest("session.mcp.resources.list", { ...params, sessionId }), /** * Enumerate one page of resource templates a connected MCP server exposes (proxies MCP `resources/templates/list`). Pass `cursor` to continue from a prior result's `nextCursor`. * @@ -31979,7 +32252,28 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns One page of resource templates advertised by the named MCP server. */ listTemplates: async (params: McpResourcesListTemplatesRequest): Promise => - connection.sendRequest("session.mcp.resources.listTemplates", { sessionId, ...params }), + connection.sendRequest("session.mcp.resources.listTemplates", { ...params, sessionId }), + }, + /** @experimental */ + prompts: { + /** + * Enumerate one page of prompts a connected MCP server exposes (proxies MCP `prompts/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + * + * @param params MCP server whose prompts to enumerate. + * + * @returns One page of prompts advertised by the named MCP server. + */ + list: async (params: McpPromptsListRequest): Promise => + connection.sendRequest("session.mcp.prompts.list", { ...params, sessionId }), + /** + * Get a prompt's messages from a connected MCP server (proxies MCP `prompts/get`). Content is preserved as opaque JSON. Does not send messages to the model, execute tools, or fetch referenced resources. + * + * @param params MCP server, prompt name, and optional string-valued arguments. + * + * @returns Prompt messages returned by the MCP server without sending them to the model. + */ + get: async (params: McpPromptsGetRequest): Promise => + connection.sendRequest("session.mcp.prompts.get", { ...params, sessionId }), }, }, /** @experimental */ @@ -31992,7 +32286,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Per-source session diagnostics configuration. */ configure: async (params: DiagnosticsConfigureRequest): Promise => - connection.sendRequest("session.diagnostics.configure", { sessionId, ...params }), + connection.sendRequest("session.diagnostics.configure", { ...params, sessionId }), /** * Reads a bounded batch of retained session diagnostics for the selected sources. Records are never consumed and each reader advances independently through its opaque cursor. * @@ -32001,7 +32295,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns One cursor-addressed page of retained session diagnostics. */ read: async (params: DiagnosticsReadRequest): Promise => - connection.sendRequest("session.diagnostics.read", { sessionId, ...params }), + connection.sendRequest("session.diagnostics.read", { ...params, sessionId }), }, /** @experimental */ connectors: { @@ -32034,7 +32328,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Validated Connector catalog snapshot cached by the session. */ list: async (params: ConnectorAccountRequest): Promise => - connection.sendRequest("session.connectors.list", { sessionId, ...params }), + connection.sendRequest("session.connectors.list", { ...params, sessionId }), /** * Refreshes and validates the Connector catalog for the pinned opaque account selection. * @@ -32043,7 +32337,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Validated Connector catalog snapshot cached by the session. */ refresh: async (params: ConnectorAccountRequest): Promise => - connection.sendRequest("session.connectors.refresh", { sessionId, ...params }), + connection.sendRequest("session.connectors.refresh", { ...params, sessionId }), /** * Initiates an idempotent Connector connection request without opening a browser. Returns connected when the service is immediately authoritative, consent_required with a validated URL, or pending with an opaque continuation ID. * @@ -32052,7 +32346,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Typed result of initiating or continuing a Connector connection. */ connect: async (params: ConnectorConnectRequest): Promise => - connection.sendRequest("session.connectors.connect", { sessionId, ...params }), + connection.sendRequest("session.connectors.connect", { ...params, sessionId }), /** * Re-initiates an idempotent Connector connection request without browser or UI effects, with the same typed outcomes as connect. * @@ -32061,7 +32355,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Typed result of initiating or continuing a Connector connection. */ reconnect: async (params: ConnectorConnectRequest): Promise => - connection.sendRequest("session.connectors.reconnect", { sessionId, ...params }), + connection.sendRequest("session.connectors.reconnect", { ...params, sessionId }), /** * Continues a pending Connector connection with caller-supplied attempt, interval, and deadline bounds. The runtime never opens the returned consent URL. * @@ -32070,7 +32364,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Typed result of initiating or continuing a Connector connection. */ continueConnection: async (params: ConnectorContinueRequest): Promise => - connection.sendRequest("session.connectors.continueConnection", { sessionId, ...params }), + connection.sendRequest("session.connectors.continueConnection", { ...params, sessionId }), /** * Disconnects one Connector for the pinned opaque account selection, refreshes the authoritative catalog, and removes its session-owned MCP projection. * @@ -32079,7 +32373,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Authoritative result after disconnect and MCP reconciliation. */ disconnect: async (params: ConnectorConnectRequest): Promise => - connection.sendRequest("session.connectors.disconnect", { sessionId, ...params }), + connection.sendRequest("session.connectors.disconnect", { ...params, sessionId }), /** * Reconciles the authoritative cached or freshly requested Connector catalog into the session Connector MCP projection and returns live status. * @@ -32088,7 +32382,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Authoritative session connector state. Account IDs are opaque routing identifiers and credentials are never included. */ reconcile: async (params: ConnectorReconcileRequest): Promise => - connection.sendRequest("session.connectors.reconcile", { sessionId, ...params }), + connection.sendRequest("session.connectors.reconcile", { ...params, sessionId }), }, /** @experimental */ managedSettings: { @@ -32117,14 +32411,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of installing a plugin. */ install: async (params: SessionPluginsInstallRequest): Promise => - connection.sendRequest("session.plugins.install", { sessionId, ...params }), + connection.sendRequest("session.plugins.install", { ...params, sessionId }), /** * Uninstalls a plugin when permitted by the live session's retained managed policy. * * @param params Name (or spec) of the plugin to uninstall. */ uninstall: async (params: PluginsUninstallRequest): Promise => - connection.sendRequest("session.plugins.uninstall", { sessionId, ...params }), + connection.sendRequest("session.plugins.uninstall", { ...params, sessionId }), /** * Updates an installed plugin using the live session's authoritative account, working directory, and retained managed policy. * @@ -32133,21 +32427,21 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of updating a single plugin. */ update: async (params: PluginsUpdateRequest): Promise => - connection.sendRequest("session.plugins.update", { sessionId, ...params }), + connection.sendRequest("session.plugins.update", { ...params, sessionId }), /** * Enables installed plugins when permitted by the live session's retained managed policy. * * @param params Plugin names (or specs) to enable in the session's authoritative working directory. */ enable: async (params: SessionPluginsEnableRequest): Promise => - connection.sendRequest("session.plugins.enable", { sessionId, ...params }), + connection.sendRequest("session.plugins.enable", { ...params, sessionId }), /** * Disables installed plugins when permitted by the live session's retained managed policy. * * @param params Plugin names (or specs) to disable in the session's authoritative working directory. */ disable: async (params: SessionPluginsDisableRequest): Promise => - connection.sendRequest("session.plugins.disable", { sessionId, ...params }), + connection.sendRequest("session.plugins.disable", { ...params, sessionId }), /** @experimental */ marketplaces: { /** @@ -32165,7 +32459,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of registering a new marketplace. */ add: async (params: PluginsMarketplacesAddRequest): Promise => - connection.sendRequest("session.plugins.marketplaces.add", { sessionId, ...params }), + connection.sendRequest("session.plugins.marketplaces.add", { ...params, sessionId }), /** * Removes a marketplace when permitted by the live session's retained managed policy. * @@ -32174,7 +32468,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Outcome of the remove attempt, including dependent-plugin info when applicable. */ remove: async (params: PluginsMarketplacesRemoveRequest): Promise => - connection.sendRequest("session.plugins.marketplaces.remove", { sessionId, ...params }), + connection.sendRequest("session.plugins.marketplaces.remove", { ...params, sessionId }), /** * Browses a marketplace resolved through the live session's working directory and retained managed policy. * @@ -32183,7 +32477,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Plugins advertised by the marketplace. */ browse: async (params: PluginsMarketplacesBrowseRequest): Promise => - connection.sendRequest("session.plugins.marketplaces.browse", { sessionId, ...params }), + connection.sendRequest("session.plugins.marketplaces.browse", { ...params, sessionId }), /** * Refreshes marketplaces resolved through the live session's working directory and retained managed policy. * @@ -32192,7 +32486,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of refreshing one or more marketplace catalogs. */ refresh: async (params?: SessionPluginsMarketplacesRefreshRequest): Promise => - connection.sendRequest("session.plugins.marketplaces.refresh", { sessionId, ...params }), + connection.sendRequest("session.plugins.marketplaces.refresh", { ...params, sessionId }), }, /** * Reloads the session's plugin set, refreshing MCP servers, custom agents, hooks, and skills cache so SDK-driven changes via `server.plugins.*` take effect immediately. @@ -32200,7 +32494,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Optional flags controlling which side effects the reload performs. */ reload: async (params?: SessionPluginsReloadRequest): Promise => - connection.sendRequest("session.plugins.reload", { sessionId, ...params }), + connection.sendRequest("session.plugins.reload", { ...params, sessionId }), }, /** @experimental */ provider: { @@ -32212,7 +32506,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns A snapshot of the provider endpoint the session is currently configured to talk to. */ getEndpoint: async (params?: SessionProviderGetEndpointRequest): Promise => - connection.sendRequest("session.provider.getEndpoint", { sessionId, ...params }), + connection.sendRequest("session.provider.getEndpoint", { ...params, sessionId }), /** * Adds BYOK providers and/or models to the session's registry at runtime, extending the additive registry built from the session's `providers`/`models` options. Both fields are optional, so a call may add providers only, models only, or both. Within a single call providers are registered before models, so a model may reference a provider added in the same call; across calls a model may reference any provider already registered (from session creation or a prior add). A model whose referenced provider is not registered by the end of the call is rejected. Newly added models become selectable via `model.list` / `model.switchTo` and are inherited by sub-agents spawned afterwards. * @@ -32221,7 +32515,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The selectable model entries synthesized for the models added by this call. */ add: async (params: ProviderAddRequest): Promise => - connection.sendRequest("session.provider.add", { sessionId, ...params }), + connection.sendRequest("session.provider.add", { ...params, sessionId }), /** * Atomically updates the session's BYOK provider and model registry by applying the supplied snapshot, replacing existing entries, updating models, or removing entries absent from the snapshot. * @@ -32230,7 +32524,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The selectable model entries and selection ids synthesized for the synchronized BYOK models. */ sync: async (params: ProviderSyncRequest): Promise => - connection.sendRequest("session.provider.sync", { sessionId, ...params }), + connection.sendRequest("session.provider.sync", { ...params, sessionId }), /** * Withdraws named host-managed models from the session's BYOK registry, leaving every other entry untouched. The scoped counterpart to `provider.sync`: a snapshot can only describe entries the caller knows about, so using it to remove one model silently withdraws rows registered by another source, such as a plugin calling `provider.add` at runtime. Naming what to remove leaves unrelated entries alone. Selection ids that are not registered are ignored, so withdrawal is idempotent. A provider is removed only when one of the withdrawn models was the last entry referencing it; a provider that simply has no models, which is the normal state while its rows are supplied by catalog discovery, is left in place. * @@ -32239,7 +32533,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns What the withdrawal actually removed from the registry. */ withdraw: async (params: ProviderWithdrawRequest): Promise => - connection.sendRequest("session.provider.withdraw", { sessionId, ...params }), + connection.sendRequest("session.provider.withdraw", { ...params, sessionId }), }, /** @experimental */ options: { @@ -32251,7 +32545,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the session options patch was applied successfully. */ update: async (params: SessionUpdateOptionsParams): Promise => - connection.sendRequest("session.options.update", { sessionId, ...params }), + connection.sendRequest("session.options.update", { ...params, sessionId }), }, /** @experimental */ lsp: { @@ -32261,7 +32555,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Parameters for (re)loading the merged LSP configuration set. */ initialize: async (params: LspInitializeRequest): Promise => - connection.sendRequest("session.lsp.initialize", { sessionId, ...params }), + connection.sendRequest("session.lsp.initialize", { ...params, sessionId }), }, /** @experimental */ extensions: { @@ -32278,14 +32572,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Source-qualified extension identifier to enable for the session. */ enable: async (params: ExtensionsEnableRequest): Promise => - connection.sendRequest("session.extensions.enable", { sessionId, ...params }), + connection.sendRequest("session.extensions.enable", { ...params, sessionId }), /** * Disables an extension for the session. * * @param params Source-qualified extension identifier to disable for the session. */ disable: async (params: ExtensionsDisableRequest): Promise => - connection.sendRequest("session.extensions.disable", { sessionId, ...params }), + connection.sendRequest("session.extensions.disable", { ...params, sessionId }), /** * Reloads extension definitions and processes for the session. */ @@ -32297,7 +32591,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Parameters for session.extensions.sendAttachmentsToMessage. */ sendAttachmentsToMessage: async (params: SendAttachmentsToMessageParams): Promise => - connection.sendRequest("session.extensions.sendAttachmentsToMessage", { sessionId, ...params }), + connection.sendRequest("session.extensions.sendAttachmentsToMessage", { ...params, sessionId }), }, /** @experimental */ tools: { @@ -32309,7 +32603,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Canonical result returned by a session tool. */ execute: async (params: ToolsExecuteRequest): Promise => - connection.sendRequest("session.tools.execute", { sessionId, ...params }), + connection.sendRequest("session.tools.execute", { ...params, sessionId }), /** * Returns the Rust-owned built-in tool descriptors used to construct the session's offered tool set. * @@ -32318,7 +32612,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Rust-owned built-in tool descriptors for the session. */ getBuiltinDescriptors: async (params: ToolsGetBuiltinDescriptorsRequest): Promise => - connection.sendRequest("session.tools.getBuiltinDescriptors", { sessionId, ...params }), + connection.sendRequest("session.tools.getBuiltinDescriptors", { ...params, sessionId }), /** * Projects a completed task_complete tool call into its label-safe session event payload. * @@ -32327,7 +32621,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Task completion notification with summary from the agent */ taskCompleteEventData: async (params: ToolsTaskCompleteEventDataRequest): Promise => - connection.sendRequest("session.tools.taskCompleteEventData", { sessionId, ...params }), + connection.sendRequest("session.tools.taskCompleteEventData", { ...params, sessionId }), /** * Provides the result for a pending external tool call. * @@ -32336,7 +32630,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the external tool call result was handled successfully. */ handlePendingToolCall: async (params: HandlePendingToolCallRequest): Promise => - connection.sendRequest("session.tools.handlePendingToolCall", { sessionId, ...params }), + connection.sendRequest("session.tools.handlePendingToolCall", { ...params, sessionId }), /** * Resolves, builds, and validates the runtime tool list for the session. * @@ -32359,7 +32653,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Empty result after replacing the calling connection's externally implemented tools. */ set: async (params: ToolsSetRequest): Promise => - connection.sendRequest("session.tools.set", { sessionId, ...params }), + connection.sendRequest("session.tools.set", { ...params, sessionId }), /** * Sets the current session's live subagent settings override, which takes precedence over persisted user settings until cleared. Persisted user settings remain the source of truth for future sessions. * @@ -32368,7 +32662,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Empty result after applying subagent settings */ updateSubagentSettings: async (params: UpdateSubagentSettingsRequest): Promise => - connection.sendRequest("session.tools.updateSubagentSettings", { sessionId, ...params }), + connection.sendRequest("session.tools.updateSubagentSettings", { ...params, sessionId }), }, /** @experimental */ commands: { @@ -32380,7 +32674,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Slash commands available in the session, after applying any include/exclude filters. */ list: async (params?: SessionCommandsListRequest): Promise => - connection.sendRequest("session.commands.list", { sessionId, ...params }), + connection.sendRequest("session.commands.list", { ...params, sessionId }), /** * Invokes a slash command in the session. * @@ -32389,7 +32683,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). */ invoke: async (params: CommandsInvokeRequest): Promise => - connection.sendRequest("session.commands.invoke", { sessionId, ...params }), + connection.sendRequest("session.commands.invoke", { ...params, sessionId }), /** * Reports completion of a pending client-handled slash command. * @@ -32398,7 +32692,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending client-handled command was completed successfully. */ handlePendingCommand: async (params: CommandsHandlePendingCommandRequest): Promise => - connection.sendRequest("session.commands.handlePendingCommand", { sessionId, ...params }), + connection.sendRequest("session.commands.handlePendingCommand", { ...params, sessionId }), /** * Executes a slash command synchronously and returns any error. * @@ -32407,7 +32701,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Error message produced while executing the command, if any. */ execute: async (params: ExecuteCommandParams): Promise => - connection.sendRequest("session.commands.execute", { sessionId, ...params }), + connection.sendRequest("session.commands.execute", { ...params, sessionId }), /** * Enqueues a slash command for FIFO processing on the local session. * @@ -32416,7 +32710,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the command was accepted into the local execution queue. */ enqueue: async (params: EnqueueCommandParams): Promise => - connection.sendRequest("session.commands.enqueue", { sessionId, ...params }), + connection.sendRequest("session.commands.enqueue", { ...params, sessionId }), /** * Reports whether the host actually executed a queued command and whether to continue processing. * @@ -32425,7 +32719,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the queued-command response was matched to a pending request. */ respondToQueuedCommand: async (params: CommandsRespondToQueuedCommandRequest): Promise => - connection.sendRequest("session.commands.respondToQueuedCommand", { sessionId, ...params }), + connection.sendRequest("session.commands.respondToQueuedCommand", { ...params, sessionId }), }, /** @experimental */ telemetry: { @@ -32442,7 +32736,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @param params Feature override key/value pairs to attach to subsequent telemetry events from this session. */ setFeatureOverrides: async (params: TelemetrySetFeatureOverridesRequest): Promise => - connection.sendRequest("session.telemetry.setFeatureOverrides", { sessionId, ...params }), + connection.sendRequest("session.telemetry.setFeatureOverrides", { ...params, sessionId }), }, /** @experimental */ ui: { @@ -32454,7 +32748,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Completed transient query. Ordered chunks and the terminal outcome are also delivered through `ui.ephemeral_query` session events while it runs. */ ephemeralQuery: async (params: UIEphemeralQueryRequest): Promise => - connection.sendRequest("session.ui.ephemeralQuery", { sessionId, ...params }), + connection.sendRequest("session.ui.ephemeralQuery", { ...params, sessionId }), /** * Requests structured input from a UI-capable client. * @@ -32463,7 +32757,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The elicitation response (accept with form values, decline, or cancel) */ elicitation: async (params: UIElicitationRequest): Promise => - connection.sendRequest("session.ui.elicitation", { sessionId, ...params }), + connection.sendRequest("session.ui.elicitation", { ...params, sessionId }), /** * Provides the user response for a pending elicitation request. * @@ -32472,7 +32766,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the elicitation response was accepted; false if it was already resolved by another client. */ handlePendingElicitation: async (params: UIHandlePendingElicitationRequest): Promise => - connection.sendRequest("session.ui.handlePendingElicitation", { sessionId, ...params }), + connection.sendRequest("session.ui.handlePendingElicitation", { ...params, sessionId }), /** * Resolves a pending `user_input.requested` event with the user's response. * @@ -32481,7 +32775,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending UI request was resolved by this call. */ handlePendingUserInput: async (params: UIHandlePendingUserInputRequest): Promise => - connection.sendRequest("session.ui.handlePendingUserInput", { sessionId, ...params }), + connection.sendRequest("session.ui.handlePendingUserInput", { ...params, sessionId }), /** * Resolves a pending `sampling.requested` event with a sampling result, or rejects it. * @@ -32490,7 +32784,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending UI request was resolved by this call. */ handlePendingSampling: async (params: UIHandlePendingSamplingRequest): Promise => - connection.sendRequest("session.ui.handlePendingSampling", { sessionId, ...params }), + connection.sendRequest("session.ui.handlePendingSampling", { ...params, sessionId }), /** * Resolves a pending `auto_mode_switch.requested` event with the user's accept/decline decision. * @@ -32499,7 +32793,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending UI request was resolved by this call. */ handlePendingAutoModeSwitch: async (params: UIHandlePendingAutoModeSwitchRequest): Promise => - connection.sendRequest("session.ui.handlePendingAutoModeSwitch", { sessionId, ...params }), + connection.sendRequest("session.ui.handlePendingAutoModeSwitch", { ...params, sessionId }), /** * Resolves a pending `session_limits_exhausted.requested` event with the user's selected limit action. * @@ -32508,7 +32802,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending UI request was resolved by this call. */ handlePendingSessionLimitsExhausted: async (params: UIHandlePendingSessionLimitsExhaustedRequest): Promise => - connection.sendRequest("session.ui.handlePendingSessionLimitsExhausted", { sessionId, ...params }), + connection.sendRequest("session.ui.handlePendingSessionLimitsExhausted", { ...params, sessionId }), /** * Resolves a pending `exit_plan_mode.requested` event with the user's response. * @@ -32517,7 +32811,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the pending UI request was resolved by this call. */ handlePendingExitPlanMode: async (params: UIHandlePendingExitPlanModeRequest): Promise => - connection.sendRequest("session.ui.handlePendingExitPlanMode", { sessionId, ...params }), + connection.sendRequest("session.ui.handlePendingExitPlanMode", { ...params, sessionId }), /** * Registers an in-process handler for auto-mode-switch requests so the server bridge skips dispatch. * @@ -32533,7 +32827,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the handle was active and the registration count was decremented. */ unregisterDirectAutoModeSwitchHandler: async (params: UIUnregisterDirectAutoModeSwitchHandlerRequest): Promise => - connection.sendRequest("session.ui.unregisterDirectAutoModeSwitchHandler", { sessionId, ...params }), + connection.sendRequest("session.ui.unregisterDirectAutoModeSwitchHandler", { ...params, sessionId }), }, /** @experimental */ permissions: { @@ -32545,7 +32839,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ configure: async (params: PermissionsConfigureParams): Promise => - connection.sendRequest("session.permissions.configure", { sessionId, ...params }), + connection.sendRequest("session.permissions.configure", { ...params, sessionId }), /** * Provides a decision for a pending tool permission request. * @@ -32554,7 +32848,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the permission decision was applied; false when the request was already resolved. */ handlePendingPermissionRequest: async (params: PermissionDecisionRequest): Promise => - connection.sendRequest("session.permissions.handlePendingPermissionRequest", { sessionId, ...params }), + connection.sendRequest("session.permissions.handlePendingPermissionRequest", { ...params, sessionId }), /** * Reconstructs the set of pending tool permission requests from the session's event history. * @@ -32570,7 +32864,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ setApproveAll: async (params: PermissionsSetApproveAllRequest): Promise => - connection.sendRequest("session.permissions.setApproveAll", { sessionId, ...params }), + connection.sendRequest("session.permissions.setApproveAll", { ...params, sessionId }), /** * Sets the permission mode for the session. `manual` follows the normal approval flow, `assisted` attaches LLM safety recommendations, and `allow-all` automatically approves permission requests. The result returns the authoritative post-mutation mode so callers can update local state without racing the `session.permissions_changed` notification. * @@ -32579,7 +32873,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the requested permission mode was applied and reports the authoritative post-mutation mode. */ setMode: async (params: PermissionsSetModeRequest): Promise => - connection.sendRequest("session.permissions.setMode", { sessionId, ...params }), + connection.sendRequest("session.permissions.setMode", { ...params, sessionId }), /** * Returns the current permission mode for the session. * @@ -32595,7 +32889,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ modifyRules: async (params: PermissionsModifyRulesParams): Promise => - connection.sendRequest("session.permissions.modifyRules", { sessionId, ...params }), + connection.sendRequest("session.permissions.modifyRules", { ...params, sessionId }), /** * Sets whether the client wants permission prompts bridged into session events. * @@ -32604,7 +32898,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ setRequired: async (params: PermissionsSetRequiredRequest): Promise => - connection.sendRequest("session.permissions.setRequired", { sessionId, ...params }), + connection.sendRequest("session.permissions.setRequired", { ...params, sessionId }), /** * Clears session-scoped tool approvals and, for full resets, exact session-approved paths. * @@ -32613,7 +32907,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ resetSessionApprovals: async (params: PermissionsResetSessionApprovalsRequest): Promise => - connection.sendRequest("session.permissions.resetSessionApprovals", { sessionId, ...params }), + connection.sendRequest("session.permissions.resetSessionApprovals", { ...params, sessionId }), /** * Notifies the runtime that a permission prompt UI has been shown to the user. * @@ -32622,7 +32916,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ notifyPromptShown: async (params: PermissionPromptShownNotification): Promise => - connection.sendRequest("session.permissions.notifyPromptShown", { sessionId, ...params }), + connection.sendRequest("session.permissions.notifyPromptShown", { ...params, sessionId }), /** @experimental */ paths: { /** @@ -32640,7 +32934,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ add: async (params: PermissionPathsAddParams): Promise => - connection.sendRequest("session.permissions.paths.add", { sessionId, ...params }), + connection.sendRequest("session.permissions.paths.add", { ...params, sessionId }), /** * Updates the session's primary working directory used by the permission policy. * @@ -32649,7 +32943,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ updatePrimary: async (params: PermissionPathsUpdatePrimaryParams): Promise => - connection.sendRequest("session.permissions.paths.updatePrimary", { sessionId, ...params }), + connection.sendRequest("session.permissions.paths.updatePrimary", { ...params, sessionId }), /** * Reports whether a path falls within any of the session's allowed directories. * @@ -32658,7 +32952,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the supplied path is within the session's allowed directories. */ isPathWithinAllowedDirectories: async (params: PermissionPathsAllowedCheckParams): Promise => - connection.sendRequest("session.permissions.paths.isPathWithinAllowedDirectories", { sessionId, ...params }), + connection.sendRequest("session.permissions.paths.isPathWithinAllowedDirectories", { ...params, sessionId }), /** * Reports whether a path falls within the session's workspace (primary) directory. * @@ -32667,7 +32961,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the supplied path is within the session's workspace directory. */ isPathWithinWorkspace: async (params: PermissionPathsWorkspaceCheckParams): Promise => - connection.sendRequest("session.permissions.paths.isPathWithinWorkspace", { sessionId, ...params }), + connection.sendRequest("session.permissions.paths.isPathWithinWorkspace", { ...params, sessionId }), }, /** @experimental */ locations: { @@ -32679,7 +32973,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Resolved location-permissions key and type. */ resolve: async (params: PermissionLocationResolveParams): Promise => - connection.sendRequest("session.permissions.locations.resolve", { sessionId, ...params }), + connection.sendRequest("session.permissions.locations.resolve", { ...params, sessionId }), /** * Applies persisted location-scoped tool approvals and allowed directories for a working directory to this session's permission service. * @@ -32688,7 +32982,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Summary of persisted location permissions applied to the session. */ apply: async (params: PermissionLocationApplyParams): Promise => - connection.sendRequest("session.permissions.locations.apply", { sessionId, ...params }), + connection.sendRequest("session.permissions.locations.apply", { ...params, sessionId }), /** * Persists a tool approval for a permission location and applies its rules to this session's live permission service. * @@ -32697,7 +32991,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ addToolApproval: async (params: PermissionLocationAddToolApprovalParams): Promise => - connection.sendRequest("session.permissions.locations.addToolApproval", { sessionId, ...params }), + connection.sendRequest("session.permissions.locations.addToolApproval", { ...params, sessionId }), }, /** @experimental */ folderTrust: { @@ -32709,7 +33003,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Folder trust check result. */ isTrusted: async (params: FolderTrustCheckParams): Promise => - connection.sendRequest("session.permissions.folderTrust.isTrusted", { sessionId, ...params }), + connection.sendRequest("session.permissions.folderTrust.isTrusted", { ...params, sessionId }), /** * Adds a folder to the user's trusted folders list. * @@ -32718,7 +33012,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ addTrusted: async (params: FolderTrustAddParams): Promise => - connection.sendRequest("session.permissions.folderTrust.addTrusted", { sessionId, ...params }), + connection.sendRequest("session.permissions.folderTrust.addTrusted", { ...params, sessionId }), }, /** @experimental */ urls: { @@ -32730,7 +33024,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ setUnrestrictedMode: async (params: PermissionUrlsSetUnrestrictedModeParams): Promise => - connection.sendRequest("session.permissions.urls.setUnrestrictedMode", { sessionId, ...params }), + connection.sendRequest("session.permissions.urls.setUnrestrictedMode", { ...params, sessionId }), }, }, /** @@ -32743,7 +33037,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @experimental */ log: async (params: LogRequest): Promise => - connection.sendRequest("session.log", { sessionId, ...params }), + connection.sendRequest("session.log", { ...params, sessionId }), /** @experimental */ metadata: { /** @@ -32768,7 +33062,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Client-owned, case-sensitive string metadata persisted with a local session. Clients should namespace keys by owner. Keys must be non-empty and at most 256 UTF-8 bytes; keys under `copilot/` and `github/` are reserved. Values may contain at most 16 KiB of UTF-8 data. A bag may contain at most 128 entries and its serialized sidecar may contain at most 64 KiB. The runtime stores but never interprets these values. */ updateClientMetadata: async (params: MetadataUpdateClientMetadataRequest): Promise => - connection.sendRequest("session.metadata.updateClientMetadata", { sessionId, ...params }), + connection.sendRequest("session.metadata.updateClientMetadata", { ...params, sessionId }), /** * Reports whether the local session is currently processing user/agent messages. * @@ -32791,7 +33085,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Token breakdown for the session's current context window, or null if uninitialized. */ contextInfo: async (params: MetadataContextInfoRequest): Promise => - connection.sendRequest("session.metadata.contextInfo", { sessionId, ...params }), + connection.sendRequest("session.metadata.contextInfo", { ...params, sessionId }), /** * Returns the experimental per-source attribution breakdown of the session's current context window as a flat list of entries (skills, subagents, MCP servers, built-in tools, plugin rollups, system/tool-definition costs, with nesting via parentId), plus the successful compaction count. The heaviest individual messages are available separately via `metadata.getContextHeaviestMessages`. Returns null until the session has initialized its system prompt and tool metadata. * @@ -32807,7 +33101,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns The heaviest individual messages in the session's context window, most-expensive first. */ getContextHeaviestMessages: async (params: MetadataContextHeaviestMessagesRequest): Promise => - connection.sendRequest("session.metadata.getContextHeaviestMessages", { sessionId, ...params }), + connection.sendRequest("session.metadata.getContextHeaviestMessages", { ...params, sessionId }), /** * Records a working-directory/git context change and emits a `session.context_changed` event. For a local session, a report whose `cwd` diverges from the session's current working directory is ignored (the call still succeeds but records nothing and emits no event): a local session's working directory is authoritative and is moved via `metadata.setWorkingDirectory` (or an SDK `session.resume` that supplies a `workingDirectory`), not by this method. * @@ -32816,7 +33110,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Notify the session that its working directory context has changed. Emits a `session.context_changed` event so consumers (telemetry, OTel tracker, ACP, the timeline UI) can react. Use this when the host has detected a cwd/branch/repo change outside the session's normal lifecycle (e.g., after a shell command in interactive mode). For a local session, a report whose `cwd` diverges from the session's current working directory is ignored (the call still succeeds but records nothing and emits no event); move a local session's working directory via `metadata.setWorkingDirectory` instead. */ recordContextChange: async (params: MetadataRecordContextChangeRequest): Promise => - connection.sendRequest("session.metadata.recordContextChange", { sessionId, ...params }), + connection.sendRequest("session.metadata.recordContextChange", { ...params, sessionId }), /** * Updates the session's working directory. For local sessions the target is validated first (an absolute path that exists on disk) and the permission primary directory is re-based; a rejected validation fails the call before any session state changes. * @@ -32825,7 +33119,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. */ setWorkingDirectory: async (params: MetadataSetWorkingDirectoryRequest): Promise => - connection.sendRequest("session.metadata.setWorkingDirectory", { sessionId, ...params }), + connection.sendRequest("session.metadata.setWorkingDirectory", { ...params, sessionId }), /** * Re-tokenizes the session's existing messages against a model and returns aggregate token totals. * @@ -32834,7 +33128,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Re-tokenize the session's existing messages against `modelId` and return the token totals. Useful for hosts that want an initial estimate of context usage on session resume, before the next agent turn fires `session.context_info_changed` events. Returns zeros for an empty session. */ recomputeContextTokens: async (params: MetadataRecomputeContextTokensRequest): Promise => - connection.sendRequest("session.metadata.recomputeContextTokens", { sessionId, ...params }), + connection.sendRequest("session.metadata.recomputeContextTokens", { ...params, sessionId }), }, /** @experimental */ contentExclusion: { @@ -32846,19 +33140,19 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Batch content-exclusion result. Callers must fail closed when policy evaluation is unavailable. */ checkPaths: async (params: ContentExclusionCheckPathsRequest): Promise => - connection.sendRequest("session.contentExclusion.checkPaths", { sessionId, ...params }), + connection.sendRequest("session.contentExclusion.checkPaths", { ...params, sessionId }), }, /** @experimental */ shell: { /** - * Starts a shell command and streams output through session notifications. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via "shell.kill", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via "setsid") leaves the signalled group, so either can leave a background process running. + * Starts a shell command, returning an RPC error if it cannot be spawned. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via "shell.kill", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via "setsid") leaves the signalled group, so either can leave a background process running. * - * @param params Shell command to run, with optional working directory and timeout in milliseconds. + * @param params Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error. * - * @returns Identifier of the spawned process, used to correlate streamed output and exit notifications. + * @returns Identifier of the spawned shell process, usable with shell.kill while the process is running. */ exec: async (params: ShellExecRequest): Promise => - connection.sendRequest("session.shell.exec", { sessionId, ...params }), + connection.sendRequest("session.shell.exec", { ...params, sessionId }), /** * Sends a signal to a shell process previously started via "shell.exec". The signal targets the command's whole process group (POSIX) or job object (Windows), so descendants still in that group are signalled too, not just the direct child. On POSIX a descendant that moved itself into a new session or process group (for example via "setsid") is no longer in the signalled group and survives. * @@ -32867,7 +33161,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the signal was delivered; false if the process was unknown or already exited. */ kill: async (params: ShellKillRequest): Promise => - connection.sendRequest("session.shell.kill", { sessionId, ...params }), + connection.sendRequest("session.shell.kill", { ...params, sessionId }), /** * Executes a user-requested shell command through the session runtime. * @@ -32876,7 +33170,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of a user-requested shell command. */ executeUserRequested: async (params: ShellExecuteUserRequestedRequest): Promise => - connection.sendRequest("session.shell.executeUserRequested", { sessionId, ...params }), + connection.sendRequest("session.shell.executeUserRequested", { ...params, sessionId }), /** * Cancels a user-requested shell command by request ID. * @@ -32885,7 +33179,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Cancellation result for a user-requested shell command. */ cancelUserRequested: async (params: ShellCancelUserRequestedRequest): Promise => - connection.sendRequest("session.shell.cancelUserRequested", { sessionId, ...params }), + connection.sendRequest("session.shell.cancelUserRequested", { ...params, sessionId }), }, /** @experimental */ history: { @@ -32897,7 +33191,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Compaction outcome with the number of tokens and messages removed, summary text, and the resulting context window breakdown. */ compact: async (params?: SessionHistoryCompactRequest): Promise => - connection.sendRequest("session.history.compact", { sessionId, ...params }), + connection.sendRequest("session.history.compact", { ...params, sessionId }), /** * Truncates persisted session history to a specific event. * @@ -32906,7 +33200,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Number of events that were removed by the truncation. */ truncate: async (params: HistoryTruncateRequest): Promise => - connection.sendRequest("session.history.truncate", { sessionId, ...params }), + connection.sendRequest("session.history.truncate", { ...params, sessionId }), /** * Lists the user turns that the session can rewind to. Never rejects for a busy session: rewind reads need the session's file-change captures to be settled, so a session that still holds active work answers with `unavailableReason: "session-busy"` and no points, which the caller can retry. * @@ -32922,7 +33216,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Files and aggregate changes for a prospective rewind. */ previewRewind: async (params: HistoryPreviewRewindRequest): Promise => - connection.sendRequest("session.history.previewRewind", { sessionId, ...params }), + connection.sendRequest("session.history.previewRewind", { ...params, sessionId }), /** * Rewinds the session conversation, optionally restoring files changed by the discarded turns. Not crash-atomic: file restore and conversation truncation are separate stores, applied in that order, so a process crash between them can leave the workspace rewound while the conversation still contains the discarded turns. There is no recovery journal; re-running the same rewind is the recovery path for a crash before truncation lands, since file restore is idempotent (already-restored files are reported as skipped) and truncation is re-derived from the still-retained boundary event. After truncation lands that boundary no longer exists, so the same request is rejected; the only stage that can still be outstanding is snapshot pruning, whose failure leaves orphan snapshots the capture store tolerates. The reverse inconsistency cannot occur, because truncation is never applied before file restore succeeds. * @@ -32931,7 +33225,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Structured outcome of a rewind request. */ rewind: async (params: HistoryRewindRequest): Promise => - connection.sendRequest("session.history.rewind", { sessionId, ...params }), + connection.sendRequest("session.history.rewind", { ...params, sessionId }), /** * Cancels any in-progress background compaction on a local session. * @@ -32961,7 +33255,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns What a successful clear removed. A clear that could not be applied rejects instead of reporting a count. */ clearContext: async (params: HistoryClearContextRequest): Promise => - connection.sendRequest("session.history.clearContext", { sessionId, ...params }), + connection.sendRequest("session.history.clearContext", { ...params, sessionId }), }, /** @experimental */ queue: { @@ -32980,7 +33274,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of moving a queued item. */ moveItem: async (params: QueueMoveItemRequest): Promise => - connection.sendRequest("session.queue.moveItem", { sessionId, ...params }), + connection.sendRequest("session.queue.moveItem", { ...params, sessionId }), /** * Inserts a new queued message at a public visible position. * @@ -32989,7 +33283,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of inserting a queued message. */ insertAt: async (params: QueueInsertAtRequest): Promise => - connection.sendRequest("session.queue.insertAt", { sessionId, ...params }), + connection.sendRequest("session.queue.insertAt", { ...params, sessionId }), /** * Removes an addressable queued item by its stable id. * @@ -32998,7 +33292,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of removing a queued item. */ removeAt: async (params: QueueRemoveAtRequest): Promise => - connection.sendRequest("session.queue.removeAt", { sessionId, ...params }), + connection.sendRequest("session.queue.removeAt", { ...params, sessionId }), /** * Updates the text of an addressable single-message queue item. * @@ -33007,7 +33301,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of editing a queued message. */ updateText: async (params: QueueUpdateTextRequest): Promise => - connection.sendRequest("session.queue.updateText", { sessionId, ...params }), + connection.sendRequest("session.queue.updateText", { ...params, sessionId }), /** * Atomically withdraws an unchanged user message of a local session: from the queued or steering lane while unconsumed, or from the running turn it started while the model has not answered it and nothing the user sent after it is pending. Withdrawing from the running turn interrupts that turn and removes its events from history. A client retaining the original draft may restore it only when removed is true. * @@ -33016,7 +33310,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of withdrawing a user message. */ withdrawMessage: async (params: QueueWithdrawMessageRequest): Promise => - connection.sendRequest("session.queue.withdrawMessage", { sessionId, ...params }), + connection.sendRequest("session.queue.withdrawMessage", { ...params, sessionId }), /** * Atomically appends text and attachments to an unchanged, unconsumed local steering message. Returns updated=false if delivery or withdrawal already claimed the message. * @@ -33025,7 +33319,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of editing a queued message. */ appendSteering: async (params: QueueAppendSteeringRequest): Promise => - connection.sendRequest("session.queue.appendSteering", { sessionId, ...params }), + connection.sendRequest("session.queue.appendSteering", { ...params, sessionId }), /** * Duplicates an addressable queued item immediately after its source. * @@ -33034,14 +33328,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of duplicating a queued item. */ duplicateAt: async (params: QueueDuplicateAtRequest): Promise => - connection.sendRequest("session.queue.duplicateAt", { sessionId, ...params }), + connection.sendRequest("session.queue.duplicateAt", { ...params, sessionId }), /** * Acquires or releases the queued-lane drain pause. * * @param params Parameters for acquiring or releasing the queued-lane drain pause. Acquisition is exclusive and non-idempotent: `paused: true` against an already-paused session fails with `queue_already_paused`. The pause is never released automatically — it is not tied to the caller's lifetime, so a client that exits without sending `paused: false` leaves the lane frozen. Release is unowned: `paused: false` clears the pause for any caller, including one that never acquired it. */ setDrainPaused: async (params: QueueSetDrainPausedRequest): Promise => - connection.sendRequest("session.queue.setDrainPaused", { sessionId, ...params }), + connection.sendRequest("session.queue.setDrainPaused", { ...params, sessionId }), /** * Moves an addressable queued message into the live turn's steering lane. * @@ -33050,7 +33344,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Result of trying to steer a queued message into a live turn. */ sendNow: async (params: QueueSendNowRequest): Promise => - connection.sendRequest("session.queue.sendNow", { sessionId, ...params }), + connection.sendRequest("session.queue.sendNow", { ...params, sessionId }), /** * Removes the most recently queued user-facing item (LIFO). * @@ -33074,7 +33368,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Batch of session events returned by a read, with cursor and continuation metadata. */ read: async (params: EventLogReadRequest): Promise => - connection.sendRequest("session.eventLog.read", { sessionId, ...params }), + connection.sendRequest("session.eventLog.read", { ...params, sessionId }), /** * Returns a snapshot of the current tail cursor without consuming events. * @@ -33090,7 +33384,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Opaque handle representing an event-type interest registration. */ registerInterest: async (params: RegisterEventInterestParams): Promise => - connection.sendRequest("session.eventLog.registerInterest", { sessionId, ...params }), + connection.sendRequest("session.eventLog.registerInterest", { ...params, sessionId }), /** * Releases a consumer's previously-registered interest in an event type. * @@ -33099,7 +33393,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Indicates whether the operation succeeded. */ releaseInterest: async (params: ReleaseEventInterestParams): Promise => - connection.sendRequest("session.eventLog.releaseInterest", { sessionId, ...params }), + connection.sendRequest("session.eventLog.releaseInterest", { ...params, sessionId }), }, /** @experimental */ usage: { @@ -33121,7 +33415,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Prediction result. Available results include prediction details; unavailable results include an explicit reason. */ predict: async (params?: SessionLimitPredictionPredictRequest): Promise => - connection.sendRequest("session.limitPrediction.predict", { sessionId, ...params }), + connection.sendRequest("session.limitPrediction.predict", { ...params, sessionId }), }, /** @experimental */ remote: { @@ -33133,7 +33427,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns GitHub URL for the session and a flag indicating whether remote steering is enabled. */ enable: async (params: RemoteEnableRequest): Promise => - connection.sendRequest("session.remote.enable", { sessionId, ...params }), + connection.sendRequest("session.remote.enable", { ...params, sessionId }), /** * Disables remote session export and steering. */ @@ -33147,7 +33441,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Persist a steerability change as a `session.remote_steerable_changed` event. Used by the host (CLI / SDK consumer) when it has just finished enabling or disabling steering on a remote exporter that the runtime does not directly own. */ notifySteerableChanged: async (params: RemoteNotifySteerableChangedRequest): Promise => - connection.sendRequest("session.remote.notifySteerableChanged", { sessionId, ...params }), + connection.sendRequest("session.remote.notifySteerableChanged", { ...params, sessionId }), }, /** @experimental */ visibility: { @@ -33166,7 +33460,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Effective sharing status and shareable GitHub URL after updating session visibility. */ set: async (params: VisibilitySetRequest): Promise => - connection.sendRequest("session.visibility.set", { sessionId, ...params }), + connection.sendRequest("session.visibility.set", { ...params, sessionId }), }, /** @experimental */ schedule: { @@ -33185,7 +33479,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * @returns Remove a scheduled prompt by id. The result entry is omitted if the id was unknown. */ stop: async (params: ScheduleStopRequest): Promise => - connection.sendRequest("session.schedule.stop", { sessionId, ...params }), + connection.sendRequest("session.schedule.stop", { ...params, sessionId }), }, }; } @@ -33205,7 +33499,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @experimental */ sendSystemNotification: async (params: SendSystemNotificationRequest): Promise => - connection.sendRequest("session.sendSystemNotification", { sessionId, ...params }), + connection.sendRequest("session.sendSystemNotification", { ...params, sessionId }), /** @experimental */ gitHubAuth: { /** @@ -33237,14 +33531,14 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Authentication credentials accepted only at native protocol ingress. Runtime outputs use credential-free `AuthIdentity` metadata. */ login: async (params: SessionAuthLoginRequest): Promise => - connection.sendRequest("session.gitHubAuth.login", { sessionId, ...params }), + connection.sendRequest("session.gitHubAuth.login", { ...params, sessionId }), /** * Switches the session to another available authentication. * * @param params Parameters for switching the session's active authentication. */ switchToAuth: async (params: SessionAuthSwitchRequest): Promise => - connection.sendRequest("session.gitHubAuth.switchToAuth", { sessionId, ...params }), + connection.sendRequest("session.gitHubAuth.switchToAuth", { ...params, sessionId }), /** * Logs out the session's current GitHub authentication. * @@ -33260,7 +33554,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Whether the requested authentication was logged out. */ logoutUser: async (params: SessionAuthLogoutUserRequest): Promise => - connection.sendRequest("session.gitHubAuth.logoutUser", { sessionId, ...params }), + connection.sendRequest("session.gitHubAuth.logoutUser", { ...params, sessionId }), /** * Gets validation errors from the most recent authentication attempt. * @@ -33279,14 +33573,14 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @param params Internal canvas provider registration parameters. */ register: async (params: CanvasProviderRegisterRequest): Promise => - connection.sendRequest("session.canvas.provider.register", { sessionId, ...params }), + connection.sendRequest("session.canvas.provider.register", { ...params, sessionId }), /** * Unregisters an internal canvas provider connection. * * @param params Internal canvas provider unregistration parameters. */ unregister: async (params: CanvasProviderUnregisterRequest): Promise => - connection.sendRequest("session.canvas.provider.unregister", { sessionId, ...params }), + connection.sendRequest("session.canvas.provider.unregister", { ...params, sessionId }), }, }, /** @experimental */ @@ -33299,7 +33593,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Complete current or terminal workflow run envelope. */ runFromTool: async (params: WorkflowToolRunRequest): Promise => - connection.sendRequest("session.workflow.runFromTool", { sessionId, ...params }), + connection.sendRequest("session.workflow.runFromTool", { ...params, sessionId }), /** * Internal tool-originated dynamic workflow resume. * @@ -33308,14 +33602,14 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Resolved persisted workflow identity and resumed run envelope. */ resumeFromTool: async (params: WorkflowToolResumeRequest): Promise => - connection.sendRequest("session.workflow.resumeFromTool", { sessionId, ...params }), + connection.sendRequest("session.workflow.resumeFromTool", { ...params, sessionId }), /** * Atomically pauses an owned dynamic workflow attempt at a durable checkpoint. * * @param params Parameters for an owned durable pause checkpoint. */ pauseAtCheckpoint: async (params: WorkflowPauseCheckpointRequest): Promise => - connection.sendRequest("session.workflow.pauseAtCheckpoint", { sessionId, ...params }), + connection.sendRequest("session.workflow.pauseAtCheckpoint", { ...params, sessionId }), }, /** @experimental */ model: { @@ -33327,7 +33621,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns The model identifier active on the session after the switch. */ applyStartupOverlay: async (params: ModelApplyStartupOverlayRequest): Promise => - connection.sendRequest("session.model.applyStartupOverlay", { sessionId, ...params }), + connection.sendRequest("session.model.applyStartupOverlay", { ...params, sessionId }), }, /** @experimental */ mcp: { @@ -33339,7 +33633,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns MCP server startup filtering result. */ reloadWithConfig: async (params: McpReloadWithConfigRequest): Promise => - connection.sendRequest("session.mcp.reloadWithConfig", { sessionId, ...params }), + connection.sendRequest("session.mcp.reloadWithConfig", { ...params, sessionId }), /** * Configures the built-in GitHub MCP server for the session's current auth context. * @@ -33348,21 +33642,21 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Result of configuring GitHub MCP. */ configureGitHub: async (params: McpConfigureGitHubRequest): Promise => - connection.sendRequest("session.mcp.configureGitHub", { sessionId, ...params }), + connection.sendRequest("session.mcp.configureGitHub", { ...params, sessionId }), /** * Registers a pre-connected external MCP client (e.g. IDE) on the session's host. The caller retains lifecycle ownership of the client and transport. Marked internal because the `client` and `transport` arguments are in-process MCP SDK instances that cannot be serialized across the JSON-RPC boundary; once the CLI moves on top of the SDK, external clients will be expressed as transport configs the runtime can construct itself. * * @param params Registration parameters for an external MCP client. */ registerExternalClient: async (params: McpRegisterExternalClientRequest): Promise => - connection.sendRequest("session.mcp.registerExternalClient", { sessionId, ...params }), + connection.sendRequest("session.mcp.registerExternalClient", { ...params, sessionId }), /** * Unregisters a previously registered external MCP client by server name. Marked internal as the paired companion of `registerExternalClient`: only in-process callers that registered a client this way can meaningfully unregister it. Disappears alongside `registerExternalClient`: once external clients are described to the runtime as config rather than handed in as instances, lifecycle (including deregistration) is owned entirely by the runtime. * * @param params Server name identifying the external client to remove. */ unregisterExternalClient: async (params: McpUnregisterExternalClientRequest): Promise => - connection.sendRequest("session.mcp.unregisterExternalClient", { sessionId, ...params }), + connection.sendRequest("session.mcp.unregisterExternalClient", { ...params, sessionId }), }, /** @experimental */ connectors: { @@ -33374,7 +33668,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Authoritative session connector state. Account IDs are opaque routing identifiers and credentials are never included. */ reconcileForStartup: async (params: ConnectorAccountRequest): Promise => - connection.sendRequest("session.connectors.reconcileForStartup", { sessionId, ...params }), + connection.sendRequest("session.connectors.reconcileForStartup", { ...params, sessionId }), /** * Removes the runtime-owned Connector MCP projection without changing service-side connections. * @@ -33393,7 +33687,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Whether finalizing the invocation effect succeeded, and the failure reason when it did not. */ finalizeInvocationEffect: async (params: CommandsFinalizeInvocationEffectRequest): Promise => - connection.sendRequest("session.commands.finalizeInvocationEffect", { sessionId, ...params }), + connection.sendRequest("session.commands.finalizeInvocationEffect", { ...params, sessionId }), }, /** @experimental */ settings: { @@ -33412,7 +33706,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Result of evaluating a Rust-owned settings predicate. */ evaluatePredicate: async (params: SessionSettingsEvaluatePredicateRequest): Promise => - connection.sendRequest("session.settings.evaluatePredicate", { sessionId, ...params }), + connection.sendRequest("session.settings.evaluatePredicate", { ...params, sessionId }), }, /** @experimental */ queue: { @@ -33438,7 +33732,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Whether a deferred-idle drain should run. */ beginDeferredIdleDrain: async (params: QueueBeginDeferredIdleDrainRequest): Promise => - connection.sendRequest("session.queue.beginDeferredIdleDrain", { sessionId, ...params }), + connection.sendRequest("session.queue.beginDeferredIdleDrain", { ...params, sessionId }), /** * Finishes a native deferred-idle drain and reports whether to drain queue work or emit idle. * @@ -33447,14 +33741,14 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Action selected by the native deferred-idle drain. */ finishDeferredIdleDrain: async (params: QueueFinishDeferredIdleDrainRequest): Promise => - connection.sendRequest("session.queue.finishDeferredIdleDrain", { sessionId, ...params }), + connection.sendRequest("session.queue.finishDeferredIdleDrain", { ...params, sessionId }), /** * Marks session.idle as deferred by native background work state. * * @param params Inputs for marking session.idle deferred in native state. */ deferSessionIdle: async (params: QueueDeferSessionIdleRequest): Promise => - connection.sendRequest("session.queue.deferSessionIdle", { sessionId, ...params }), + connection.sendRequest("session.queue.deferSessionIdle", { ...params, sessionId }), /** * Consumes queued native system notifications matching an internal filter. * @@ -33463,7 +33757,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Indicates whether a user-facing pending item was removed. */ consumeSystemNotifications: async (params: QueueConsumeSystemNotificationsRequest): Promise => - connection.sendRequest("session.queue.consumeSystemNotifications", { sessionId, ...params }), + connection.sendRequest("session.queue.consumeSystemNotifications", { ...params, sessionId }), /** * Enqueues the internal resume-pending wake item when orphan handling needs a follow-up turn. * @@ -33499,7 +33793,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Result of registering or re-arming a scheduled prompt. */ add: async (params: ScheduleAddRequest): Promise => - connection.sendRequest("session.schedule.add", { sessionId, ...params }), + connection.sendRequest("session.schedule.add", { ...params, sessionId }), /** * Registers a recurring cron scheduled prompt. * @@ -33508,7 +33802,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Result of registering or re-arming a scheduled prompt. */ addCron: async (params: ScheduleAddCronRequest): Promise => - connection.sendRequest("session.schedule.addCron", { sessionId, ...params }), + connection.sendRequest("session.schedule.addCron", { ...params, sessionId }), /** * Registers an absolute-time scheduled prompt. * @@ -33517,7 +33811,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Result of registering or re-arming a scheduled prompt. */ addAt: async (params: ScheduleAddAtRequest): Promise => - connection.sendRequest("session.schedule.addAt", { sessionId, ...params }), + connection.sendRequest("session.schedule.addAt", { ...params, sessionId }), /** * Registers a self-paced scheduled prompt. * @@ -33526,7 +33820,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Result of registering or re-arming a scheduled prompt. */ addSelfPaced: async (params: ScheduleAddSelfPacedRequest): Promise => - connection.sendRequest("session.schedule.addSelfPaced", { sessionId, ...params }), + connection.sendRequest("session.schedule.addSelfPaced", { ...params, sessionId }), /** * Re-arms an active self-paced scheduled prompt. * @@ -33535,7 +33829,7 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI * @returns Result of registering or re-arming a scheduled prompt. */ rearmSelfPaced: async (params: ScheduleRearmSelfPacedRequest): Promise => - connection.sendRequest("session.schedule.rearmSelfPaced", { sessionId, ...params }), + connection.sendRequest("session.schedule.rearmSelfPaced", { ...params, sessionId }), }, }; } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 06032163f5..7804f92e00 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -6974,6 +6974,12 @@ export interface ToolExecutionProgressData { * Human-readable progress status message (e.g., from an MCP server) */ progressMessage: string; + /** + * Client-only structured progress metadata. Not model-facing tool output. + * + * @experimental + */ + structuredContent?: JsonValue; /** * Tool call ID this progress notification belongs to */ @@ -11801,6 +11807,10 @@ export interface AutoModeResolvedData { * The routing method the server applied, when Auto Intent ran */ routingMethod?: string; + /** + * Short human-readable sentence from the routing service explaining why this model was chosen, for display alongside the model. Present only when the service supplied one: it is omitted for on-device selections, when the service did not provide an explanation, and when a replayed decision made no routing call. The text is display-only and drawn from a fixed catalogue; several distinct routing categories share identical wording, so it cannot be used to recover the category or keyed on programmatically. + */ + selectionReason?: string; /** * Whether a sticky model choice overrode the router result */ diff --git a/nodejs/test/e2e/commit_trailer_resume.e2e.test.ts b/nodejs/test/e2e/commit_trailer_resume.e2e.test.ts new file mode 100644 index 0000000000..ac30d3817a --- /dev/null +++ b/nodejs/test/e2e/commit_trailer_resume.e2e.test.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, onTestFinished } from "vitest"; +import { approveAll, RuntimeConnection, type SessionConfig } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Commit trailer resume", async () => { + const { createClient, openAiEndpoint } = await createSdkTestContext(); + + it.each(["options update", "cold resume"])( + "allows coauthor opt-in after an initial opt-out via %s", + async (transition) => { + const config: SessionConfig = { + model: "claude-sonnet-5", + coauthorEnabled: false, + featureFlags: { SESSION_TRAILER: true }, + onPermissionRequest: approveAll, + availableTools: [], + skipCustomInstructions: true, + }; + const first = createClient({ connection: RuntimeConnection.forStdio() }); + onTestFinished(() => first.stop()); + let session = await first.createSession(config); + const sessionId = session.sessionId; + const disabledResponse = await session.sendAndWait({ + prompt: "Reply with exactly COAUTHOR_DISABLED_OK.", + }); + expect(disabledResponse?.data.content).toContain("COAUTHOR_DISABLED_OK"); + const disabledRequest = (await openAiEndpoint.getExchanges()).at(-1); + const disabledSystem = disabledRequest?.request.messages.find( + (message) => message.role === "system" + )?.content; + expect(typeof disabledSystem).toBe("string"); + expect(disabledSystem).not.toContain(""); + + if (transition === "options update") { + await session.rpc.options.update({ coauthorEnabled: true }); + } else { + await session.disconnect(); + await first.stop(); + const second = createClient({ connection: RuntimeConnection.forStdio() }); + onTestFinished(() => second.stop()); + session = await second.resumeSession(sessionId, { + ...config, + coauthorEnabled: true, + }); + } + + const enabledResponse = await session.sendAndWait({ + prompt: "Reply with exactly COAUTHOR_ENABLED_OK.", + }); + expect(enabledResponse?.data.content).toContain("COAUTHOR_ENABLED_OK"); + const enabledRequest = (await openAiEndpoint.getExchanges()).at(-1); + const enabledSystem = enabledRequest?.request.messages.find( + (message) => message.role === "system" + )?.content; + expect(enabledSystem).toContain("Co-authored-by: Copilot"); + expect(enabledSystem).toContain(`Copilot-Session: ${sessionId}`); + await session.disconnect(); + } + ); + + it.each([false, true])( + "restores uncustomized attribution across cold resume (quoted tags: %s)", + async (quotedTags) => { + const config: SessionConfig = { + model: "claude-sonnet-5", + coauthorEnabled: true, + featureFlags: { SESSION_TRAILER: true }, + onPermissionRequest: approveAll, + availableTools: [], + skipCustomInstructions: true, + systemMessage: { + mode: "customize", + sections: { + custom_instructions: { + action: "append", + content: quotedTags + ? "Quoted example: before" + : "", + }, + runtime_instructions: { + action: (content: string) => + content + .replace("Co-authored-by: ", "Co-authored-by: Team ") + .replace( + "", + "TRAILER_POLICY_ONCE\n" + ), + }, + last_instructions: { + action: "append", + content: quotedTags + ? "Quoted example: after" + : "", + }, + }, + }, + }; + const first = createClient({ connection: RuntimeConnection.forStdio() }); + onTestFinished(() => first.stop()); + const session = await first.createSession(config); + const sessionId = session.sessionId; + const response = await session.sendAndWait({ + prompt: "Reply with exactly TRAILER_INITIAL_OK.", + }); + expect(response?.data.content).toContain("TRAILER_INITIAL_OK"); + const before = (await openAiEndpoint.getExchanges()).at(-1); + const initialSystem = before?.request.messages.find( + (message) => message.role === "system" + )?.content; + expect(typeof initialSystem).toBe("string"); + const initialBlocks = String(initialSystem).match( + /[\s\S]*?<\/git_commit_trailer>/g + ); + expect(initialBlocks).toHaveLength(quotedTags ? 3 : 1); + const generated = initialBlocks?.[quotedTags ? 1 : 0]; + expect(generated).toContain(`Copilot-Session: ${sessionId}`); + expect(generated).toContain("Co-authored-by: Team Copilot"); + expect(generated?.match(/TRAILER_POLICY_ONCE/g)).toHaveLength(1); + await session.disconnect(); + await first.stop(); + + const second = createClient({ connection: RuntimeConnection.forStdio() }); + onTestFinished(() => second.stop()); + const resumed = await second.resumeSession(sessionId, config); + const resumedResponse = await resumed.sendAndWait({ + prompt: "Reply with exactly TRAILER_RESUMED_OK.", + }); + expect(resumedResponse?.data.content).toContain("TRAILER_RESUMED_OK"); + const after = (await openAiEndpoint.getExchanges()).at(-1); + const resumedSystem = after?.request.messages.find( + (message) => message.role === "system" + )?.content; + expect( + String(resumedSystem).match(/[\s\S]*?<\/git_commit_trailer>/g) + ).toEqual(initialBlocks); + await resumed.disconnect(); + await second.stop(); + + const third = createClient({ connection: RuntimeConnection.forStdio() }); + onTestFinished(() => third.stop()); + const optedOut = await third.resumeSession(sessionId, { + ...config, + coauthorEnabled: false, + }); + const finalResponse = await optedOut.sendAndWait({ + prompt: "Reply with exactly TRAILER_OPT_OUT_OK.", + }); + expect(finalResponse?.data.content).toContain("TRAILER_OPT_OUT_OK"); + const finalRequest = (await openAiEndpoint.getExchanges()).at(-1); + const finalSystem = finalRequest?.request.messages.find( + (message) => message.role === "system" + )?.content; + expect(finalSystem).not.toContain(`Copilot-Session: ${sessionId}`); + expect(finalSystem).not.toContain("Co-authored-by:"); + await optedOut.disconnect(); + } + ); +}); diff --git a/nodejs/test/e2e/harness/CapiProxy.test.ts b/nodejs/test/e2e/harness/CapiProxy.test.ts new file mode 100644 index 0000000000..8f2375d4fa --- /dev/null +++ b/nodejs/test/e2e/harness/CapiProxy.test.ts @@ -0,0 +1,340 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawn } from "child_process"; +import { once } from "node:events"; +import { existsSync } from "node:fs"; +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createInterface } from "node:readline"; +import { beforeEach, describe, expect, inject, it, onTestFinished, vi } from "vitest"; +import { CapiProxy } from "./CapiProxy"; +import { CAPI_PROXY_BUNDLE } from "./proxyBundleContext"; +import { hasChildExited, stopChildProcess, waitForChildExit } from "./sdkTestHelper"; + +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn(actual.spawn) }; +}); + +vi.mock("./sdkTestHelper", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stopChildProcess: vi.fn(actual.stopChildProcess), + waitForChildExit: vi.fn(actual.waitForChildExit), + }; +}); + +const realSpawn = vi.mocked(spawn).getMockImplementation()!; +const realWaitForChildExit = vi.mocked(waitForChildExit).getMockImplementation()!; + +async function startOwnedProxy(): Promise { + const proxy = new CapiProxy(); + const starting = proxy.start(); + onTestFinished(async () => { + await Promise.allSettled([starting]); + await proxy.stop(true); + }); + await starting; + return proxy; +} + +describe("bundled CAPI proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("launches the shared bundle directly and waits for a clean shutdown", async () => { + const proxy = await startOwnedProxy(); + const serverPath = inject(CAPI_PROXY_BUNDLE); + expect(serverPath).toMatch(/server\.mjs$/); + expect(existsSync(serverPath)).toBe(true); + expect(spawn).toHaveBeenCalledExactlyOnceWith(process.execPath, [serverPath], { + stdio: ["ignore", "pipe", "inherit"], + windowsHide: true, + }); + const child = vi.mocked(spawn).mock.results[0].value; + expect(child.exitCode).toBeNull(); + expect(proxy.getProxyEnv()).toMatchObject({ + HTTPS_PROXY: expect.stringMatching(/^http:\/\/127\.0\.0\.1:/), + NODE_EXTRA_CA_CERTS: expect.any(String), + }); + expect(existsSync(proxy.getProxyEnv().NODE_EXTRA_CA_CERTS)).toBe(true); + + const proxyUrl = proxy.url; + await proxy.stop(true); + expect(child.exitCode).toBe(0); + expect(child.signalCode).toBeNull(); + expect(() => proxy.url).toThrow("has not been started"); + await expect(fetch(proxyUrl)).rejects.toThrow(); + }); + + it("shares only the bundle, not authentication state, ports, or certificates", async () => { + const [first, second] = await Promise.all([startOwnedProxy(), startOwnedProxy()]); + expect(first.url).not.toBe(second.url); + expect(first.getProxyEnv().HTTPS_PROXY).not.toBe(second.getProxyEnv().HTTPS_PROXY); + expect(first.getProxyEnv().NODE_EXTRA_CA_CERTS).not.toBe( + second.getProxyEnv().NODE_EXTRA_CA_CERTS + ); + expect(vi.mocked(spawn).mock.calls.map((call) => call[1])).toEqual([ + [inject(CAPI_PROXY_BUNDLE)], + [inject(CAPI_PROXY_BUNDLE)], + ]); + + await first.setCopilotUserByToken("isolated-token", { login: "first-user" }); + await second.setCopilotUserByToken("isolated-token", { login: "second-user" }); + for (const [proxy, login] of [ + [first, "first-user"], + [second, "second-user"], + ] as const) { + const response = await fetch(`${proxy.url}/copilot_internal/user`, { + headers: { Authorization: "Bearer isolated-token" }, + }); + expect(response.ok).toBe(true); + expect(await response.json()).toMatchObject({ login }); + } + }); + + it("runs the self-contained bundle from a path containing spaces", async () => { + const directory = await mkdtemp(join(tmpdir(), "sdk proxy bundle ")); + const proxy = new CapiProxy(); + let starting: Promise | undefined; + onTestFinished(async () => { + await Promise.allSettled(starting ? [starting] : []); + try { + await proxy.stop(true); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + const serverPath = join(directory, "server with spaces.mjs"); + await copyFile(inject(CAPI_PROXY_BUNDLE), serverPath); + vi.mocked(spawn).mockImplementationOnce((command, _args, options) => + realSpawn(command, [serverPath], options) + ); + starting = proxy.start(); + await starting; + await proxy.setCopilotUserByToken("spaces-token", { login: "spaces-user" }); + const response = await fetch(`${proxy.url}/copilot_internal/user`, { + headers: { Authorization: "Bearer spaces-token" }, + }); + expect(await response.json()).toMatchObject({ login: "spaces-user" }); + }); + + it("reports early startup exit and its output", async () => { + vi.mocked(spawn).mockImplementationOnce((_command, _args, options) => + realSpawn( + process.execPath, + ["-e", 'console.log("startup failed"); process.exit(23);'], + options + ) + ); + await expect(startOwnedProxy()).rejects.toThrow( + "Proxy exited before startup with code 23: startup failed" + ); + expect(vi.mocked(spawn).mock.results[0].value.exitCode).toBe(23); + }); + + it("terminates the owned child when startup metadata is invalid", async () => { + vi.mocked(spawn).mockImplementationOnce((_command, _args, options) => + realSpawn( + process.execPath, + [ + "-e", + 'console.log("Listening: http://127.0.0.1:1 {}"); setInterval(() => {}, 1000);', + ], + options + ) + ); + await expect(startOwnedProxy()).rejects.toThrow("missing CONNECT proxy details"); + const child = vi.mocked(spawn).mock.results[0].value; + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + }); + + it("reports spawn failures without waiting for an impossible exit", async () => { + vi.mocked(spawn).mockImplementationOnce(() => + realSpawn(join(dirname(inject(CAPI_PROXY_BUNDLE)), "missing-node-executable"), [], {}) + ); + await expect(startOwnedProxy()).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("retains a child after startup cleanup fails so shutdown can retry", async () => { + const cleanupError = new Error("Child process did not exit after SIGKILL"); + vi.mocked(stopChildProcess).mockRejectedValueOnce(cleanupError); + vi.mocked(spawn).mockImplementationOnce((_command, _args, options) => + realSpawn( + process.execPath, + [ + "-e", + 'console.log("Listening: http://127.0.0.1:1 {}"); setInterval(() => {}, 1000);', + ], + options + ) + ); + const proxy = new CapiProxy(); + const starting = proxy.start(); + const child = vi.mocked(spawn).mock.results[0].value; + onTestFinished(async () => { + await Promise.allSettled([starting]); + try { + await proxy.stop(true); + } finally { + await stopChildProcess(child); + } + }); + + await expect(starting).rejects.toMatchObject({ + message: "Proxy startup and cleanup failed", + errors: [ + expect.objectContaining({ + message: expect.stringContaining("missing CONNECT proxy details"), + }), + cleanupError, + ], + }); + expect(hasChildExited(child)).toBe(false); + await expect(proxy.start()).rejects.toThrow("already been started"); + expect(spawn).toHaveBeenCalledTimes(1); + + await proxy.stop(true); + expect(hasChildExited(child)).toBe(true); + }); + + it("retains a running proxy after shutdown cleanup fails so shutdown can retry", async () => { + const proxy = new CapiProxy(); + const starting = proxy.start(); + const child = vi.mocked(spawn).mock.results[0].value; + const fetchMock = vi.spyOn(globalThis, "fetch"); + onTestFinished(async () => { + fetchMock.mockRestore(); + await Promise.allSettled([starting]); + try { + await proxy.stop(true); + } finally { + await stopChildProcess(child); + } + }); + const proxyUrl = await starting; + const proxyEnv = proxy.getProxyEnv(); + const shutdownError = new Error("Shutdown request failed"); + const cleanupError = new Error("Child process did not exit after SIGKILL"); + fetchMock.mockRejectedValueOnce(shutdownError); + vi.mocked(stopChildProcess).mockRejectedValueOnce(cleanupError); + + await expect(proxy.stop(true)).rejects.toMatchObject({ + message: "Proxy shutdown and cleanup failed", + errors: [shutdownError, cleanupError], + }); + expect(hasChildExited(child)).toBe(false); + expect(proxy.url).toBe(proxyUrl); + expect(proxy.getProxyEnv()).toEqual(proxyEnv); + await expect(proxy.start()).rejects.toThrow("already been started"); + expect(spawn).toHaveBeenCalledTimes(1); + + await proxy.stop(true); + expect(child.exitCode).toBe(0); + expect(child.signalCode).toBeNull(); + expect(() => proxy.url).toThrow("has not been started"); + expect(proxy.getProxyEnv()).toEqual({}); + await expect(fetch(proxyUrl)).rejects.toThrow(); + }); + + it("allows an acknowledged capture flush to finish beyond the old shutdown cutoff", async () => { + const directory = await mkdtemp(join(tmpdir(), "sdk proxy flush ")); + const capturePath = join(directory, "capture.yaml"); + vi.mocked(spawn).mockImplementationOnce((_command, _args, options) => + realSpawn( + process.execPath, + [ + "-e", + ` +const { createServer } = require("node:http"); +const { writeFile } = require("node:fs/promises"); +let releaseFlush; +const flushGate = new Promise(resolve => { releaseFlush = resolve; }); +const server = createServer(async (request, response) => { + if (request.url === "/stop") { + response.end(); + await writeFile(process.argv[1], "partial capture"); + console.log("Capture flush blocked"); + await flushGate; + await writeFile(process.argv[1], "complete capture"); + server.close(); + process.exit(0); + } else if (request.url === "/release") { + response.end(); + releaseFlush(); + } +}); +server.listen(0, "127.0.0.1", () => { + const url = "http://127.0.0.1:" + server.address().port; + console.log("Listening: " + url + " " + JSON.stringify({ + connectProxyUrl: url, + caFilePath: "unused.pem" + })); +}); +`, + capturePath, + ], + options + ) + ); + const proxy = new CapiProxy(); + const starting = proxy.start(); + const child = vi.mocked(spawn).mock.results[0].value; + const outputLines = createInterface({ input: child.stdout! }); + let stopping: Promise | undefined; + onTestFinished(async () => { + vi.useRealTimers(); + outputLines.close(); + try { + await stopChildProcess(child); + await Promise.allSettled([starting, ...(stopping ? [stopping] : [])]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + const proxyUrl = await starting; + const flushBlocked = once(outputLines, "line"); + let resolveExitWait!: () => void; + const exitWaitStarted = new Promise((resolve) => { + resolveExitWait = resolve; + }); + vi.mocked(waitForChildExit).mockImplementationOnce((process, timeoutMs) => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const waiting = realWaitForChildExit(process, timeoutMs); + resolveExitWait(); + return waiting; + }); + const kill = vi.spyOn(child, "kill"); + let shutdownSettled = false; + stopping = proxy.stop().then( + () => { + shutdownSettled = true; + }, + (error: unknown) => { + shutdownSettled = true; + return error; + } + ); + await exitWaitStarted; + const [output] = await flushBlocked; + expect(String(output)).toContain("Capture flush blocked"); + expect(await readFile(capturePath, "utf8")).toBe("partial capture"); + + await vi.advanceTimersByTimeAsync(10_000); + expect(shutdownSettled).toBe(false); + expect(kill).not.toHaveBeenCalled(); + expect(hasChildExited(child)).toBe(false); + + vi.useRealTimers(); + expect((await fetch(`${proxyUrl}/release`)).ok).toBe(true); + expect(await stopping).toBeUndefined(); + expect(await readFile(capturePath, "utf8")).toBe("complete capture"); + expect(child.exitCode).toBe(0); + expect(child.signalCode).toBeNull(); + }); +}); diff --git a/nodejs/test/e2e/harness/CapiProxy.ts b/nodejs/test/e2e/harness/CapiProxy.ts index c25d422f99..6c8f0f609c 100644 --- a/nodejs/test/e2e/harness/CapiProxy.ts +++ b/nodejs/test/e2e/harness/CapiProxy.ts @@ -1,15 +1,19 @@ -import { spawn } from "child_process"; -import { resolve } from "path"; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawn, type ChildProcess } from "child_process"; import { createInterface } from "readline"; -import { expect } from "vitest"; +import { expect, inject } from "vitest"; import type { CapturedRequest } from "../../../../test/harness/replayingCapiProxy"; import { CopilotUserResponse, ParsedHttpExchange, } from "../../../../test/harness/replayingCapiProxy"; import { isCI } from "./sdkTestContext"; +import { CAPI_PROXY_BUNDLE } from "./proxyBundleContext"; +import { hasChildExited, stopChildProcess, waitForChildExit } from "./sdkTestHelper"; -const HARNESS_SERVER_PATH = resolve(__dirname, "../../../../test/harness/server.ts"); const NO_PROXY = "127.0.0.1,localhost,::1"; interface ProxyStartupInfo { @@ -22,6 +26,7 @@ interface ProxyStartupInfo { export class CapiProxy { private proxyUrl: string | undefined; private startupInfo: ProxyStartupInfo | undefined; + private serverProcess: ChildProcess | undefined; /** * Returns the URL of the running proxy. Throws if the proxy has not been started. @@ -34,43 +39,75 @@ export class CapiProxy { } async start(): Promise { - const serverProcess = spawn("npx", ["tsx", HARNESS_SERVER_PATH], { + if (this.serverProcess) { + throw new Error("CapiProxy has already been started."); + } + const serverPath = inject(CAPI_PROXY_BUNDLE); + if (!serverPath) { + throw new Error("CapiProxy bundle is missing; enable the SDK Vitest global setup."); + } + const serverProcess = spawn(process.execPath, [serverPath], { stdio: ["ignore", "pipe", "inherit"], - shell: true, + windowsHide: true, }); - - this.startupInfo = await new Promise((resolve, reject) => { - const stdout = serverProcess.stdout!; - const lines: string[] = []; - const lineReader = createInterface({ input: stdout }); - const cleanup = () => { - lineReader.off("line", onLine); - serverProcess.off("exit", onExit); - lineReader.close(); - }; - const onLine = (line: string) => { - lines.push(line); - try { - const info = tryParseStartupInfo(line); - if (!info) { - return; + this.serverProcess = serverProcess; + + try { + this.startupInfo = await new Promise((resolve, reject) => { + const stdout = serverProcess.stdout!; + const lines: string[] = []; + const lineReader = createInterface({ input: stdout }); + const cleanup = () => { + lineReader.off("line", onLine); + serverProcess.off("exit", onExit); + serverProcess.off("error", onError); + lineReader.close(); + }; + const onLine = (line: string) => { + lines.push(line); + try { + const info = tryParseStartupInfo(line); + if (!info) { + return; + } + cleanup(); + resolve(info); + } catch (error) { + cleanup(); + reject(error); } + }; + const onExit = (code: number | null) => { cleanup(); - resolve(info); - } catch (error) { + reject( + new Error( + `Proxy exited before startup with code ${code}: ${lines.join("\n")}` + ) + ); + }; + const onError = (error: Error) => { cleanup(); reject(error); + }; + lineReader.on("line", onLine); + serverProcess.once("exit", onExit); + serverProcess.once("error", onError); + }); + } catch (error) { + try { + // A failed spawn has no PID and cannot emit an exit event. + if (serverProcess.pid !== undefined) { + await stopChildProcess(serverProcess); } - }; - const onExit = (code: number | null) => { - cleanup(); - reject( - new Error(`Proxy exited before startup with code ${code}: ${lines.join("\n")}`) - ); - }; - lineReader.on("line", onLine); - serverProcess.once("exit", onExit); - }); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Proxy startup and cleanup failed"); + } finally { + if (serverProcess.pid === undefined || hasChildExited(serverProcess)) { + this.serverProcess = undefined; + } + } + throw error; + } this.proxyUrl = this.startupInfo.capiProxyUrl; return this.proxyUrl; @@ -119,20 +156,53 @@ export class CapiProxy { async getExchanges(): Promise { const response = await fetch(`${this.proxyUrl}/exchanges`, { method: "GET" }); - return await response.json(); + return (await response.json()) as ParsedHttpExchange[]; } async getRequests(): Promise { const response = await fetch(`${this.proxyUrl}/requests`, { method: "GET" }); - return await response.json(); + return (await response.json()) as CapturedRequest[]; } async stop(skipWritingCache?: boolean): Promise { - const url = skipWritingCache - ? `${this.proxyUrl}/stop?skipWritingCache=true` - : `${this.proxyUrl}/stop`; - const response = await fetch(url, { method: "POST" }); - expect(response.ok).toBe(true); + const serverProcess = this.serverProcess; + if (!serverProcess) { + return; + } + try { + if (!this.proxyUrl) { + await stopChildProcess(serverProcess); + return; + } + const url = skipWritingCache + ? `${this.proxyUrl}/stop?skipWritingCache=true` + : `${this.proxyUrl}/stop`; + const response = await fetch(url, { method: "POST" }); + expect(response.ok).toBe(true); + // /stop acknowledges before captures are flushed; do not interrupt that write. + await waitForChildExit(serverProcess); + if (serverProcess.exitCode !== 0 || serverProcess.signalCode !== null) { + throw new Error( + `Proxy exited with code ${serverProcess.exitCode}, signal ${serverProcess.signalCode}` + ); + } + } catch (error) { + try { + await stopChildProcess(serverProcess); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Proxy shutdown and cleanup failed" + ); + } + throw error; + } finally { + if (serverProcess.pid === undefined || hasChildExited(serverProcess)) { + this.serverProcess = undefined; + this.proxyUrl = undefined; + this.startupInfo = undefined; + } + } } /** diff --git a/nodejs/test/e2e/harness/globalSetup.ts b/nodejs/test/e2e/harness/globalSetup.ts new file mode 100644 index 0000000000..80f6c3bf34 --- /dev/null +++ b/nodejs/test/e2e/harness/globalSetup.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { build } from "esbuild"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { TestProject } from "vitest/node"; +import { CAPI_PROXY_BUNDLE } from "./proxyBundleContext"; + +export default async function setup(project: TestProject): Promise { + const { testFiles } = await project.globTestFiles(); + if (!testFiles.some((file) => file.replaceAll("\\", "/").includes("/test/e2e/"))) { + return; + } + + const serverPath = join(project.tmpDir, "capi-proxy", "server.mjs"); + const buildBundle = async () => { + await build({ + entryPoints: [ + fileURLToPath(new URL("../../../../test/harness/server.ts", import.meta.url)), + ], + outfile: serverPath, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + // Bundled CommonJS dependencies still require Node built-ins dynamically. + banner: { + js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);', + }, + }); + }; + + await buildBundle(); + project.provide(CAPI_PROXY_BUNDLE, serverPath); + project.onTestsRerun(buildBundle); +} diff --git a/nodejs/test/e2e/harness/proxyBundleContext.ts b/nodejs/test/e2e/harness/proxyBundleContext.ts new file mode 100644 index 0000000000..ed62e6f03a --- /dev/null +++ b/nodejs/test/e2e/harness/proxyBundleContext.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export const CAPI_PROXY_BUNDLE = "capiProxyBundle"; + +declare module "vitest" { + export interface ProvidedContext { + capiProxyBundle: string; + } +} diff --git a/nodejs/test/e2e/harness/sdkTestHelper.ts b/nodejs/test/e2e/harness/sdkTestHelper.ts index 9cca10388b..965857a4f1 100644 --- a/nodejs/test/e2e/harness/sdkTestHelper.ts +++ b/nodejs/test/e2e/harness/sdkTestHelper.ts @@ -23,11 +23,11 @@ export async function stopChildProcess(child: ChildProcess): Promise { } } -function hasChildExited(child: ChildProcess): boolean { +export function hasChildExited(child: ChildProcess): boolean { return child.exitCode !== null || child.signalCode !== null; } -function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { +export function waitForChildExit(child: ChildProcess, timeoutMs?: number): Promise { if (hasChildExited(child)) { return Promise.resolve(true); } @@ -44,7 +44,8 @@ function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise finish(true); - const timeout = setTimeout(() => finish(false), timeoutMs); + const timeout = + timeoutMs === undefined ? undefined : setTimeout(() => finish(false), timeoutMs); child.once("exit", onExit); if (hasChildExited(child)) { diff --git a/nodejs/test/e2e/mcp_oauth.e2e.test.ts b/nodejs/test/e2e/mcp_oauth.e2e.test.ts index ab68afc2ef..0ec14a5ada 100644 --- a/nodejs/test/e2e/mcp_oauth.e2e.test.ts +++ b/nodejs/test/e2e/mcp_oauth.e2e.test.ts @@ -63,6 +63,66 @@ describe("MCP OAuth host auth", async () => { } ); + it( + "should complete runtime-managed OAuth through a hosted callback", + { timeout: 120_000 }, + async () => { + const oauthServer = await startOAuthMcpServer(); + const serverName = "oauth-hosted-callback-mcp"; + const redirectUri = "https://agent.example.test/oauth/callback"; + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: { + [serverName]: { + type: "http", + url: `${oauthServer.url}/mcp`, + tools: ["*"], + oauthClientId: "sdk-e2e-client", + oauthPublicClient: true, + } as unknown as MCPServerConfig, + }, + }); + onTestFinished(() => disconnectSession(session)); + + await waitForMcpServerStatus(session, serverName, "needs-auth"); + const login = await session.rpc.mcp.oauth.login({ + serverName, + redirectUri, + }); + + expect(login.authorizationUrl).toBeDefined(); + expect(login.authorizationId).toBeDefined(); + const authorizationUrl = new URL(login.authorizationUrl!); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(redirectUri); + expect(authorizationUrl.searchParams.get("state")).toBe(login.authorizationId); + + const callbackUrl = new URL(redirectUri); + callbackUrl.searchParams.set("code", "accepted-code"); + callbackUrl.searchParams.set("state", login.authorizationId!); + await session.rpc.mcp.oauth.complete({ + authorizationId: login.authorizationId!, + callbackUrl: callbackUrl.toString(), + }); + + await waitForMcpServerStatus(session, serverName); + const tools = await session.rpc.mcp.listTools({ serverName }); + expect(tools.tools.map((tool) => tool.name)).toContain("whoami"); + + const requests = await oauthServer.requests(); + const tokenRequest = requests.find((request) => request.path === "/token"); + expect(tokenRequest).toBeDefined(); + const tokenForm = new URLSearchParams(tokenRequest!.body ?? ""); + expect(tokenForm.get("grant_type")).toBe("authorization_code"); + expect(tokenForm.get("code")).toBe("accepted-code"); + expect(tokenForm.get("redirect_uri")).toBe(redirectUri); + expect(tokenForm.get("client_id")).toBe("sdk-e2e-client"); + expect(tokenForm.get("code_verifier")).toEqual(expect.any(String)); + expect( + requests.some((request) => request.authorization === `Bearer ${EXPECTED_TOKEN}`) + ).toBe(true); + } + ); + it("should satisfy MCP OAuth using host-provided token", { timeout: 120_000 }, async () => { const oauthServer = await startOAuthMcpServer(); const serverName = "oauth-protected-mcp"; @@ -349,7 +409,9 @@ async function callWhoami( async function startOAuthMcpServer(options: { cimdSupported?: boolean } = {}): Promise<{ url: string; - requests: () => Promise>; + requests: () => Promise< + Array<{ authorization: string | null; body: string | null; path: string }> + >; }> { const child = spawn(process.execPath, [TEST_MCP_OAUTH_SERVER], { env: { diff --git a/nodejs/test/e2e/mcp_remote_session_closure.e2e.test.ts b/nodejs/test/e2e/mcp_remote_session_closure.e2e.test.ts new file mode 100644 index 0000000000..83cbb29276 --- /dev/null +++ b/nodejs/test/e2e/mcp_remote_session_closure.e2e.test.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, onTestFinished } from "vitest"; +import type { CopilotSession, MCPServerConfig } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { stopChildProcess, waitForCondition } from "./harness/sdkTestHelper.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TEST_MCP_SESSION_EXPIRY_SERVER = resolve( + __dirname, + "../../../test/harness/test-mcp-session-expiry-server.mjs" +); + +interface McpServerStats { + initializations: number; + toolsListRequests: number; + toolCalls: number; + activeSessions: number; +} + +describe("MCP remote session closure", async () => { + const { copilotClient: client } = await createSdkTestContext({ + copilotClientOptions: { + env: { + COPILOT_MCP_APPS: "true", + MCP_APPS: "true", + }, + }, + }); + + it( + "reconnects after an idle Streamable HTTP session expires", + { timeout: 180_000 }, + async () => { + const remoteServer = await startSessionExpiryMcpServer(); + const serverName = "remote-server"; + const session = await client.createSession({ + onPermissionRequest: approveAll, + // `session.mcp.apps.callTool` below requires the negotiated + // `mcp-apps` capability, which only this opt-in requests. + enableMcpApps: true, + mcpServers: { + [serverName]: { + type: "http", + url: `${remoteServer.url}/mcp`, + tools: ["*"], + } as MCPServerConfig, + }, + }); + onTestFinished(() => disconnectSession(session)); + + await waitForMcpServerStatus(session, serverName, "connected"); + expect((await remoteServer.stats()).initializations).toBe(1); + + // The server drops the session the way an idle Streamable HTTP endpoint + // expires one: every later request for it answers 404. + await remoteServer.expireSessions(); + + await waitForCondition(async () => (await remoteServer.stats()).initializations >= 2, { + timeoutMs: 120_000, + intervalMs: 250, + timeoutMessage: "remote MCP server was never re-initialized after session expiry", + }); + await waitForMcpServerStatus(session, serverName, "connected"); + + const result = await session.rpc.mcp.apps.callTool({ + serverName, + originServerName: serverName, + toolName: "remote_ping", + arguments: {}, + }); + expect(result.content).toEqual([{ type: "text", text: "remote pong" }]); + + const finalStats = await remoteServer.stats(); + expect(finalStats.toolCalls).toBe(1); + } + ); +}); + +async function waitForMcpServerStatus( + session: CopilotSession, + serverName: string, + expectedStatus: string +): Promise { + let lastStatus = ""; + await waitForCondition( + async () => { + const result = await session.rpc.mcp.list(); + const server = result.servers.find((entry) => entry.name === serverName); + lastStatus = server?.status ?? ""; + return server?.status === expectedStatus; + }, + { + timeoutMs: 120_000, + intervalMs: 200, + timeoutMessage: `${serverName} did not reach ${expectedStatus}; last status was ${lastStatus}`, + } + ); +} + +async function disconnectSession(session: CopilotSession): Promise { + try { + await session.disconnect(); + } catch { + // The session may already be gone when the test finished. + } +} + +async function startSessionExpiryMcpServer(): Promise<{ + url: string; + stats: () => Promise; + expireSessions: () => Promise; +}> { + const child = spawn(process.execPath, [TEST_MCP_SESSION_EXPIRY_SERVER], { + stdio: ["ignore", "pipe", "pipe"], + }); + onTestFinished(() => stopChildProcess(child)); + + const stderr: string[] = []; + child.stderr.on("data", (chunk) => stderr.push(String(chunk))); + + const url = await new Promise((resolvePromise, reject) => { + const rl = createInterface({ input: child.stdout }); + const timeout = setTimeout(() => { + rl.close(); + reject( + new Error(`Timed out waiting for session-expiry MCP server. ${stderr.join("")}`) + ); + }, 30_000); + + child.once("exit", (code, signal) => { + clearTimeout(timeout); + rl.close(); + reject( + new Error( + `Session-expiry MCP server exited before listening. code=${code} signal=${signal} ${stderr.join("")}` + ) + ); + }); + + rl.on("line", (line) => { + const match = /^Listening: (.+)$/.exec(line); + if (!match) { + return; + } + clearTimeout(timeout); + rl.close(); + resolvePromise(match[1]); + }); + }); + + return { + url, + stats: async () => { + const response = await fetch(`${url}/__stats`); + if (!response.ok) { + throw new Error(`Failed to read MCP server stats: ${response.status}`); + } + return (await response.json()) as McpServerStats; + }, + expireSessions: async () => { + const response = await fetch(`${url}/__expire`, { method: "POST" }); + if (!response.ok) { + throw new Error(`Failed to expire MCP sessions: ${response.status}`); + } + }, + }; +} diff --git a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts index 9b0460c61d..82f5afb685 100644 --- a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts @@ -316,6 +316,106 @@ describe("Session MCP and skills RPC", async () => { await session.disconnect(); }); + it("should paginate and get MCP prompts without interpreting their content", async () => { + const serverName = "rpc-prompts-server"; + const fixtures = JSON.parse( + fs.readFileSync(path.join(TEST_HARNESS_DIR, "mcp-prompt-fixtures.json"), "utf8") + ); + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: createTestMcpServers(serverName), + }); + const changedServers: string[] = []; + const unsubscribe = session.on("mcp.prompts.list_changed", (event) => { + changedServers.push(event.data.serverName); + }); + try { + await waitForMcpServerStatus(session, serverName); + const first = await session.rpc.mcp.prompts.list({ serverName }); + expect(first).toEqual(fixtures.firstPage); + const second = await session.rpc.mcp.prompts.list({ + serverName, + cursor: first.nextCursor, + }); + expect(second).toEqual(fixtures.secondPage); + expect(second.nextCursor).toBeUndefined(); + await expectFailure( + () => session.rpc.mcp.prompts.list({ serverName, cursor: "invalid-cursor" }), + "Unknown prompt cursor" + ); + + const rich = await session.rpc.mcp.prompts.get({ + serverName, + promptName: "rich", + arguments: { topic: "opaque content", style: "", extra: "日本語" }, + }); + expect(rich).toEqual(fixtures.richPrompt); + + const isolatedSession = await client.createSession({ onPermissionRequest: approveAll }); + try { + await expect( + isolatedSession.rpc.mcp.prompts.list({ serverName }) + ).rejects.toThrow(); + await expect( + isolatedSession.rpc.mcp.prompts.get({ + serverName, + promptName: "rich", + arguments: { topic: "opaque content" }, + }) + ).rejects.toThrow(); + expect(await session.rpc.mcp.prompts.list({ serverName })).toEqual( + fixtures.firstPage + ); + } finally { + await isolatedSession.disconnect(); + } + + const argumentSets: (Record | undefined)[] = [ + undefined, + {}, + { value: "日本語", empty: "" }, + ]; + for (const args of argumentSets) { + const result = await session.rpc.mcp.prompts.get({ + serverName, + promptName: "echo", + ...(args === undefined ? {} : { arguments: args }), + }); + expect(result).toEqual({ + messages: [ + { + role: "user", + content: { type: "text", text: expect.any(String) }, + }, + ], + }); + const content = result.messages[0].content as { text: string }; + expect(JSON.parse(content.text)).toEqual(args ?? null); + } + await expectFailure( + () => session.rpc.mcp.prompts.get({ serverName, promptName: "unknown" }), + "Unknown prompt" + ); + await expectFailure( + () => + session.rpc.mcp.prompts.get({ serverName, promptName: "rich", arguments: {} }), + "Missing required argument" + ); + + changedServers.length = 0; + await session.rpc.mcp.prompts.get({ serverName, promptName: "refresh" }); + await expect.poll(() => changedServers, { timeout: 30_000 }).toContain(serverName); + const refreshed = await session.rpc.mcp.prompts.list({ + serverName, + cursor: first.nextCursor, + }); + expect(refreshed.prompts).toEqual([...fixtures.secondPage.prompts, { name: "added" }]); + } finally { + unsubscribe(); + await session.disconnect(); + } + }); + it("should set mcp env value mode and remove github server", async () => { const serverName = "github"; const mcpServers = createTestMcpServers(serverName); diff --git a/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts b/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts index 9311d1aaf2..ce10918089 100644 --- a/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts +++ b/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts @@ -313,7 +313,7 @@ describe("Generated RPC surface coverage", () => { ...collectRuntimeFunctions(session.rpc, "session"), ]); - expect(inventory).toHaveLength(381); + expect(inventory).toHaveLength(384); const boundInstallationMethods = [ "mcp.prepareInstall", "mcp.applyInstall", diff --git a/nodejs/test/mcp-prompts.test.ts b/nodejs/test/mcp-prompts.test.ts new file mode 100644 index 0000000000..8272fd4062 --- /dev/null +++ b/nodejs/test/mcp-prompts.test.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import { createSessionRpc } from "../src/generated/rpc.js"; + +const fixtures = JSON.parse( + readFileSync(new URL("../../test/harness/mcp-prompt-fixtures.json", import.meta.url), "utf8") +); + +// These dispatch checks supplement the real MCP transport tests in rpc_mcp_and_skills. +describe("generated MCP prompt RPCs", () => { + it("keeps the bound session when prompt and existing RPC params contain another session ID", async () => { + const sendRequest = vi.fn().mockResolvedValue({}); + const rpc = createSessionRpc( + { sendRequest } as unknown as MessageConnection, + "bound-session" + ); + const listParams = { + sessionId: "foreign-session", + serverName: "fixture", + cursor: "opaque-cursor", + }; + const getParams = { + sessionId: "foreign-session", + serverName: "fixture", + promptName: "rich", + arguments: { topic: "preserved" }, + }; + const sendParams = { sessionId: "foreign-session", prompt: "preserved" }; + + await rpc.mcp.prompts.list(listParams); + await rpc.mcp.prompts.get(getParams); + await rpc.send(sendParams); + + expect(sendRequest.mock.calls).toEqual([ + ["session.mcp.prompts.list", { ...listParams, sessionId: "bound-session" }], + ["session.mcp.prompts.get", { ...getParams, sessionId: "bound-session" }], + ["session.send", { ...sendParams, sessionId: "bound-session" }], + ]); + }); + + it("binds the session and forwards the opaque list cursor", async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce(fixtures.firstPage) + .mockResolvedValueOnce(fixtures.secondPage); + const rpc = createSessionRpc( + { sendRequest } as unknown as MessageConnection, + "bound-session" + ); + + const first = await rpc.mcp.prompts.list({ serverName: "fixture" }); + expect(first).toEqual(fixtures.firstPage); + expect(sendRequest).toHaveBeenNthCalledWith(1, "session.mcp.prompts.list", { + sessionId: "bound-session", + serverName: "fixture", + }); + const second = await rpc.mcp.prompts.list({ + serverName: "fixture", + cursor: first.nextCursor, + }); + expect(second).toEqual(fixtures.secondPage); + expect(sendRequest).toHaveBeenNthCalledWith(2, "session.mcp.prompts.list", { + sessionId: "bound-session", + serverName: "fixture", + cursor: first.nextCursor, + }); + }); + + it.each | undefined>([undefined, {}, { topic: "日本語", style: "" }])( + "preserves optional arguments %j and opaque results", + async (args) => { + const sendRequest = vi.fn().mockResolvedValue(fixtures.richPrompt); + const rpc = createSessionRpc( + { sendRequest } as unknown as MessageConnection, + "bound-session" + ); + const params = { + serverName: "fixture", + promptName: "rich", + ...(args === undefined ? {} : { arguments: args }), + }; + expect(await rpc.mcp.prompts.get(params)).toEqual(fixtures.richPrompt); + expect(sendRequest).toHaveBeenCalledExactlyOnceWith("session.mcp.prompts.get", { + ...params, + sessionId: "bound-session", + }); + } + ); + + it("does not swallow upstream errors", async () => { + const error = new Error("Missing required argument: topic"); + const sendRequest = vi.fn().mockRejectedValue(error); + const rpc = createSessionRpc( + { sendRequest } as unknown as MessageConnection, + "bound-session" + ); + await expect( + rpc.mcp.prompts.get({ serverName: "fixture", promptName: "rich" }) + ).rejects.toBe(error); + }); +}); diff --git a/nodejs/test/shared-codegen.test.ts b/nodejs/test/shared-codegen.test.ts index 0177fd209c..1980978936 100644 --- a/nodejs/test/shared-codegen.test.ts +++ b/nodejs/test/shared-codegen.test.ts @@ -1,7 +1,9 @@ import type { JSONSchema7 } from "json-schema"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it, onTestFinished } from "vitest"; import { @@ -17,6 +19,37 @@ import { } from "../../scripts/codegen/utils.ts"; describe("shared schema definition codegen utilities", () => { + it.each(["typescript", "python", "go", "csharp"])( + "runs the %s generator through a linked entrypoint", + async (language) => { + const root = await mkdtemp(join(tmpdir(), "copilot-codegen-entrypoint-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const codegenRoot = fileURLToPath(new URL("../../scripts/codegen", import.meta.url)); + const linkedRoot = join(root, "codegen"); + await symlink( + codegenRoot, + linkedRoot, + process.platform === "win32" ? "junction" : "dir" + ); + const missingSchema = join(root, "missing-schema.json"); + const result = spawnSync( + process.execPath, + [ + join(codegenRoot, "node_modules", "tsx", "dist", "cli.mjs"), + join(linkedRoot, `${language}.ts`), + missingSchema, + missingSchema, + ], + { cwd: codegenRoot, encoding: "utf8", timeout: 30_000 } + ); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("generation failed:"); + expect(result.stderr).toContain("missing-schema.json"); + } + ); + it("selects checked-out schemas for normal nested generation with a clean environment", async () => { const root = await mkdtemp(join(tmpdir(), "copilot-nested-codegen-")); onTestFinished(() => rm(root, { recursive: true, force: true })); diff --git a/nodejs/test/typescript-codegen.test.ts b/nodejs/test/typescript-codegen.test.ts index e2343daa4c..181cb6b781 100644 --- a/nodejs/test/typescript-codegen.test.ts +++ b/nodejs/test/typescript-codegen.test.ts @@ -5,11 +5,10 @@ import { describe, expect, it } from "vitest"; import { assertNoPublicInternalReferences, filterPublicSessionEventVariants, - isTypeScriptCodegenEntrypoint, normalizeSchemaForTypeScript, tsNullableResultTypeName, } from "../../scripts/codegen/typescript.ts"; -import type { DefinitionCollections } from "../../scripts/codegen/utils.ts"; +import { isCodegenEntrypoint, type DefinitionCollections } from "../../scripts/codegen/utils.ts"; describe("typescript schema codegen", () => { it("preserves an explicit nullable reference result's named union", () => { @@ -42,7 +41,7 @@ describe("typescript schema codegen", () => { it("recognizes Windows entrypoint paths case-insensitively", () => { expect( - isTypeScriptCodegenEntrypoint( + isCodegenEntrypoint( "C:\\b\\execroot\\src\\sdk\\scripts\\codegen\\typescript.ts", "c:\\B\\execroot\\src\\sdk\\scripts\\codegen\\typescript.ts", "win32" diff --git a/nodejs/vitest.config.ts b/nodejs/vitest.config.ts index 4ac56bb45e..6d164d52fc 100644 --- a/nodejs/vitest.config.ts +++ b/nodejs/vitest.config.ts @@ -60,6 +60,7 @@ export default defineConfig({ teardownTimeout: 10000, isolate: true, // Run each test file in isolation pool: "forks", // Use process forking for better isolation + globalSetup: ["./test/e2e/harness/globalSetup.ts"], // Exclude our ad-hoc test files that aren't vitest-based exclude: [ "**/node_modules/**", diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 051e1c978b..a01b733d35 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -5222,7 +5222,7 @@ class JSONSchemaResponseFormat: name: str """Name of the output schema, subject to the provider's naming restrictions.""" - schema: Any = None + schema: Any """JSON Schema passed unchanged to the inference provider. Schemas larger than 32 MiB when JSON-encoded are rejected before admission, using the runtime's existing request-size ceiling. This is not a guarantee that the entire model request fits. Supported keywords @@ -6378,7 +6378,7 @@ def to_dict(self) -> dict: class MCPConfigureGitHubRequest: """Credential-free authentication identity used to configure GitHub MCP.""" - auth_info: Any = None + auth_info: Any """Opaque runtime auth info for GitHub MCP configuration. Marked internal: an in-process runtime shape (configureGitHubMcp is a no-op over the wire). """ @@ -7227,6 +7227,113 @@ def to_dict(self) -> dict: result["operationId"] = from_str(self.operation_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPPromptArgument: + """An argument accepted by an MCP prompt.""" + + name: str + """Name of the argument""" + + meta: dict[str, Any] | None = None + """Argument-level metadata""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard argument fields""" + + description: str | None = None + """Description of the argument""" + + required: bool | None = None + """Whether the argument is required; omission is distinct from false""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPromptArgument': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + description = from_union([from_str, from_none], obj.get("description")) + required = from_union([from_bool, from_none], obj.get("required")) + return MCPPromptArgument(name, meta, additional_properties, description, required) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.required is not None: + result["required"] = from_union([from_bool, from_none], self.required) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +class MCPPromptRole(Enum): + """The role of the message sender + + The sender role of an MCP prompt message. + """ + ASSISTANT = "assistant" + USER = "user" + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPPromptsGetRequest: + """MCP server, prompt name, and optional string-valued arguments.""" + + prompt_name: str + """The programmatic name of the prompt""" + + server_name: str + """Name of the MCP server hosting the prompt""" + + arguments: dict[str, str] | None = None + """String-valued arguments to pass to the prompt""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPromptsGetRequest': + assert isinstance(obj, dict) + prompt_name = from_str(obj.get("promptName")) + server_name = from_str(obj.get("serverName")) + arguments = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("arguments")) + return MCPPromptsGetRequest(prompt_name, server_name, arguments) + + def to_dict(self) -> dict: + result: dict = {} + result["promptName"] = from_str(self.prompt_name) + result["serverName"] = from_str(self.server_name) + if self.arguments is not None: + result["arguments"] = from_union([lambda x: from_dict(from_str, x), from_none], self.arguments) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPPromptsListRequest: + """MCP server whose prompts to enumerate.""" + + server_name: str + """Name of the MCP server whose prompts to enumerate""" + + cursor: str | None = None + """Opaque MCP pagination cursor from a prior `nextCursor` value""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPromptsListRequest': + assert isinstance(obj, dict) + server_name = from_str(obj.get("serverName")) + cursor = from_union([from_str, from_none], obj.get("cursor")) + return MCPPromptsListRequest(server_name, cursor) + + def to_dict(self) -> dict: + result: dict = {} + result["serverName"] = from_str(self.server_name) + if self.cursor is not None: + result["cursor"] = from_union([from_str, from_none], self.cursor) + return result + class MCPServerConfigType(Enum): """Local transport type. Defaults to stdio when omitted. @@ -7248,7 +7355,7 @@ class MCPServerConfigType(Enum): class MCPReloadWithConfigRequest: """Opaque MCP reload configuration.""" - config: Any = None + config: Any """Opaque runtime MCP reload configuration. Marked internal: an in-process runtime shape (reloadMcpServers throws over the wire). """ @@ -14426,45 +14533,14 @@ def to_dict(self) -> dict: result["requestId"] = from_str(self.request_id) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class ShellExecRequest: - """Shell command to run, with optional working directory and timeout in milliseconds.""" - - command: str - """Shell command to execute""" - - cwd: str | None = None - """Working directory (defaults to session working directory)""" - - timeout: int | None = None - """Timeout in milliseconds (default: 30000)""" - - @staticmethod - def from_dict(obj: Any) -> 'ShellExecRequest': - assert isinstance(obj, dict) - command = from_str(obj.get("command")) - cwd = from_union([from_str, from_none], obj.get("cwd")) - timeout = from_union([from_int, from_none], obj.get("timeout")) - return ShellExecRequest(command, cwd, timeout) - - def to_dict(self) -> dict: - result: dict = {} - result["command"] = from_str(self.command) - if self.cwd is not None: - result["cwd"] = from_union([from_str, from_none], self.cwd) - if self.timeout is not None: - result["timeout"] = from_union([from_int, from_none], self.timeout) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ShellExecResult: - """Identifier of the spawned process, used to correlate streamed output and exit - notifications. + """Identifier of the spawned shell process, usable with shell.kill while the process is + running. """ process_id: str - """Unique identifier for tracking streamed output""" + """Identifier usable with shell.kill while the process is running""" @staticmethod def from_dict(obj: Any) -> 'ShellExecResult': @@ -20134,6 +20210,33 @@ def to_dict(self) -> dict: result["resume"] = from_union([from_bool, from_none], self.resume) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPOauthCompleteRequest: + """Host-delivered callback for a runtime-managed MCP OAuth login.""" + + authorization_id: str + """Opaque identifier returned by session.mcp.oauth.login for the pending external callback.""" + + callback_url: str + """Full externally visible HTTPS callback URL received by the host, including the + authorization response query parameters. Applications behind a reverse proxy must + reconstruct the public URL rather than passing an internal proxy URL. + """ + + @staticmethod + def from_dict(obj: Any) -> 'MCPOauthCompleteRequest': + assert isinstance(obj, dict) + authorization_id = from_str(obj.get("authorizationId")) + callback_url = from_str(obj.get("callbackUrl")) + return MCPOauthCompleteRequest(authorization_id, callback_url) + + def to_dict(self) -> dict: + result: dict = {} + result["authorizationId"] = from_str(self.authorization_id) + result["callbackUrl"] = from_str(self.callback_url) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ModelSetAllowedModelsRequest: @@ -21026,6 +21129,49 @@ def to_dict(self) -> dict: ExternalToolTextResultForLlmContentResourceDetails = EmbeddedTextResourceContents | EmbeddedBlobResourceContents +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPPromptIcon: + """An MCP prompt icon with standard size hints and preserved non-standard fields.""" + + src: str + """Icon URI""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard icon fields""" + + mime_type: str | None = None + """Icon MIME type, when known""" + + sizes: list[str] | None = None + """Icon sizes, such as `48x48` or `any`""" + + theme: str | None = None + """Theme hint for this icon""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPromptIcon': + assert isinstance(obj, dict) + src = from_str(obj.get("src")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + sizes = from_union([lambda x: from_list(from_str, x), from_none], obj.get("sizes")) + theme = from_union([from_str, from_none], obj.get("theme")) + return MCPPromptIcon(src, additional_properties, mime_type, sizes, theme) + + def to_dict(self) -> dict: + result: dict = {} + result["src"] = from_str(self.src) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.sizes is not None: + result["sizes"] = from_union([lambda x: from_list(from_str, x), from_none], self.sizes) + if self.theme is not None: + result["theme"] = from_union([from_str, from_none], self.theme) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPResourceIcon: @@ -23066,7 +23212,7 @@ def to_dict(self) -> dict: @dataclass class MCPOauthLoginRequest: """Remote MCP server name and optional overrides controlling reauthentication, OAuth client - display name, callback success-page copy, and static OAuth client selection. + display name, callback handling, and static OAuth client selection. """ server_name: str """Name of the remote MCP server to authenticate""" @@ -23113,6 +23259,13 @@ class MCPOauthLoginRequest: runtime treats it as confidential and uses the per-login clientSecret if provided, otherwise retrieving the client secret from the MCP OAuth secret store. """ + redirect_uri: str | None = field(default=None, kw_only=True) + """Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When + supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and + reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not + contain query parameters or a fragment and must be registered for the selected CIMD, DCR, + or static OAuth client. + """ @staticmethod def from_dict(obj: Any) -> 'MCPOauthLoginRequest': @@ -23127,7 +23280,8 @@ def from_dict(obj: Any) -> 'MCPOauthLoginRequest': grant_type = from_union([MCPGrantType, from_none], obj.get("grantType")) login_id = from_union([from_str, from_none], obj.get("loginId")) public_client = from_union([from_bool, from_none], obj.get("publicClient")) - return MCPOauthLoginRequest(server_name, callback_success_message, client_id, client_name, client_secret, force_reauth, grant_type, public_client, login_id=login_id, expected_installation_id=expected_installation_id) + redirect_uri = from_union([from_str, from_none], obj.get("redirectUri")) + return MCPOauthLoginRequest(server_name, callback_success_message, client_id, client_name, client_secret, force_reauth, grant_type, public_client, redirect_uri=redirect_uri, login_id=login_id, expected_installation_id=expected_installation_id) def to_dict(self) -> dict: result: dict = {} @@ -23150,6 +23304,8 @@ def to_dict(self) -> dict: result["loginId"] = from_union([from_str, from_none], self.login_id) if self.public_client is not None: result["publicClient"] = from_union([from_bool, from_none], self.public_client) + if self.redirect_uri is not None: + result["redirectUri"] = from_union([from_str, from_none], self.redirect_uri) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -23332,12 +23488,18 @@ class MCPOauthLoginResult: """OAuth authorization URL the caller should open, or empty when cached tokens already authenticated the server. """ + authorization_id: str | None = None + """Opaque authorization identifier returned only for a host-managed redirect URI. The + runtime also sends it as the OAuth state value, so the callback endpoint can read state + and pass it with the full callback URL to session.mcp.oauth.complete. + """ authorization_url: str | None = None """URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already - reconnected in that case. When present, the runtime starts the callback listener before - returning and continues the flow in the background; completion is signaled via - session.mcp_server_status_changed. + reconnected in that case. For the default loopback flow, the runtime starts its listener + before returning. With redirectUri, the host receives the callback and completes it + through session.mcp.oauth.complete. The runtime continues the flow in the background and + signals completion via session.mcp_server_status_changed. """ login_id: str | None = None """Runtime-issued owned flow identity; never a server name or installation operation ID.""" @@ -23348,13 +23510,16 @@ class MCPOauthLoginResult: @staticmethod def from_dict(obj: Any) -> 'MCPOauthLoginResult': assert isinstance(obj, dict) + authorization_id = from_union([from_str, from_none], obj.get("authorizationId")) authorization_url = from_union([from_str, from_none], obj.get("authorizationUrl")) login_id = from_union([from_str, from_none], obj.get("loginId")) status = from_union([MCPOwnedOauthLoginStatus, from_none], obj.get("status")) - return MCPOauthLoginResult(authorization_url, login_id, status) + return MCPOauthLoginResult(authorization_id, authorization_url, login_id, status) def to_dict(self) -> dict: result: dict = {} + if self.authorization_id is not None: + result["authorizationId"] = from_union([from_str, from_none], self.authorization_id) if self.authorization_url is not None: result["authorizationUrl"] = from_union([from_str, from_none], self.authorization_url) if self.login_id is not None: @@ -23647,6 +23812,43 @@ 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. +@dataclass +class MCPPromptMessage: + """An MCP prompt message with opaque JSON content preserved without flattening or + content-type filtering. + """ + content: Any + """The original MCP content block, including nested metadata and unfamiliar content types""" + + role: MCPPromptRole + """The role of the message sender""" + + meta: dict[str, Any] | None = None + """Message-level metadata""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard message fields""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPromptMessage': + assert isinstance(obj, dict) + content = obj.get("content") + role = MCPPromptRole(obj.get("role")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + return MCPPromptMessage(content, role, meta, additional_properties) + + def to_dict(self) -> dict: + result: dict = {} + result["content"] = self.content + result["role"] = to_enum(MCPPromptRole, self.role) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPSamplingExecutionResult: @@ -32477,6 +32679,62 @@ 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. +@dataclass +class MCPPrompt: + """An MCP prompt descriptor. Server-provided non-standard fields are exposed under + `additionalProperties`. + """ + name: str + """The programmatic name of the prompt""" + + meta: dict[str, Any] | None = None + """Prompt-level metadata""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard descriptor fields""" + + arguments: list[MCPPromptArgument] | None = None + """Arguments accepted by the prompt""" + + description: str | None = None + """Description of what this prompt provides""" + + icons: list[MCPPromptIcon] | None = None + """Icons associated with this prompt""" + + title: str | None = None + """Human-readable display title""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPrompt': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + arguments = from_union([lambda x: from_list(MCPPromptArgument.from_dict, x), from_none], obj.get("arguments")) + description = from_union([from_str, from_none], obj.get("description")) + icons = from_union([lambda x: from_list(MCPPromptIcon.from_dict, x), from_none], obj.get("icons")) + title = from_union([from_str, from_none], obj.get("title")) + return MCPPrompt(name, meta, additional_properties, arguments, description, icons, title) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.arguments is not None: + result["arguments"] = from_union([lambda x: from_list(lambda x: to_class(MCPPromptArgument, x), x), from_none], self.arguments) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.icons is not None: + result["icons"] = from_union([lambda x: from_list(lambda x: to_class(MCPPromptIcon, x), x), from_none], self.icons) + if self.title is not None: + 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. @dataclass class MCPOauthHandlePendingRequest: @@ -33698,6 +33956,43 @@ def to_dict(self) -> dict: result["restoresPreviousConfiguration"] = from_bool(self.restores_previous_configuration) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPPromptsGetResult: + """Prompt messages returned by the MCP server without sending them to the model.""" + + messages: list[MCPPromptMessage] + """Ordered prompt messages""" + + meta: dict[str, Any] | None = None + """MCP result metadata""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard result fields""" + + description: str | None = None + """Description of the prompt""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPromptsGetResult': + assert isinstance(obj, dict) + messages = from_list(MCPPromptMessage.from_dict, obj.get("messages")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + description = from_union([from_str, from_none], obj.get("description")) + return MCPPromptsGetResult(messages, meta, additional_properties, description) + + def to_dict(self) -> dict: + result: dict = {} + result["messages"] = from_list(lambda x: to_class(MCPPromptMessage, x), self.messages) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class DebugCollectLogsEntry: @@ -38135,6 +38430,43 @@ def to_dict(self) -> dict: result["toolArgs"] = self.tool_args return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPPromptsListResult: + """One page of prompts advertised by the named MCP server.""" + + prompts: list[MCPPrompt] + """Prompts advertised by the server""" + + meta: dict[str, Any] | None = None + """MCP result metadata""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard result fields""" + + next_cursor: str | None = None + """Opaque cursor for the next page, if the server has more prompts""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPPromptsListResult': + assert isinstance(obj, dict) + prompts = from_list(MCPPrompt.from_dict, obj.get("prompts")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + next_cursor = from_union([from_str, from_none], obj.get("nextCursor")) + return MCPPromptsListResult(prompts, meta, additional_properties, next_cursor) + + def to_dict(self) -> dict: + result: dict = {} + result["prompts"] = from_list(lambda x: to_class(MCPPrompt, x), self.prompts) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.next_cursor is not None: + result["nextCursor"] = from_union([from_str, from_none], self.next_cursor) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class WorkflowRunTerminal: @@ -42666,6 +42998,11 @@ def to_dict(self) -> dict: class MCPExecuteSamplingParams: """Identifiers and raw MCP CreateMessageRequest params used to run a sampling inference.""" + mcp_request_id: Any + """The original MCP JSON-RPC request ID (string or number). Used by the runtime to correlate + the inference with the originating MCP request for telemetry; this is distinct from + `requestId` (which is the schema-level cancellation handle). + """ request: dict[str, Any] """Raw MCP CreateMessageRequest params, as received in the `sampling.requested` event. Treated as opaque at the schema layer; the runtime converts the embedded MCP messages @@ -42679,11 +43016,6 @@ class MCPExecuteSamplingParams: server_name: str """Name of the MCP server that initiated the sampling request""" - mcp_request_id: Any = None - """The original MCP JSON-RPC request ID (string or number). Used by the runtime to correlate - the inference with the originating MCP request for telemetry; this is distinct from - `requestId` (which is the schema-level cancellation handle). - """ @staticmethod def from_dict(obj: Any) -> 'MCPExecuteSamplingParams': assert isinstance(obj, dict) @@ -42780,18 +43112,18 @@ def to_dict(self) -> dict: class MCPRegisterExternalClientRequest: """Registration parameters for an external MCP client.""" - server_name: str - """Logical server name for the external client""" - - client: Any = None + client: Any """In-process MCP Client instance. Marked internal: cannot be serialized across the JSON-RPC boundary. """ - config: Any = None + config: Any """In-process server config (MCPServerConfig) paired with the in-process client/transport. Marked internal alongside its companions. """ - transport: Any = None + server_name: str + """Logical server name for the external client""" + + transport: Any """In-process MCP Transport instance. Marked internal: cannot be serialized across the JSON-RPC boundary. """ @@ -43369,10 +43701,10 @@ def to_dict(self) -> dict: class MCPServerConfigMemory: """In-process MCP server configuration used by embedded SDK clients.""" - type: MCPServerConfigMemoryType - server_instance: Any = None + server_instance: Any """In-process MCP server instance. This value cannot cross a JSON-RPC boundary.""" + type: MCPServerConfigMemoryType config_warnings: list[str] | None = None """Configuration warnings recorded while loading the server.""" @@ -45371,6 +45703,38 @@ def to_dict(self) -> dict: result["copilotUser"] = from_union([lambda x: to_class(CopilotUserResponse, x), from_none], self.copilot_user) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ShellExecRequest: + """Shell command to run, with optional working directory and timeout in milliseconds. Spawn + failures return an RPC error. + """ + command: str + """Shell command to execute""" + + cwd: str | None = None + """Working directory (defaults to session working directory)""" + + timeout: int | None = None + """Timeout in milliseconds (default: 30000)""" + + @staticmethod + def from_dict(obj: Any) -> 'ShellExecRequest': + assert isinstance(obj, dict) + command = from_str(obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + timeout = from_union([from_int, from_none], obj.get("timeout")) + return ShellExecRequest(command, cwd, timeout) + + def to_dict(self) -> dict: + result: dict = {} + result["command"] = from_str(self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.timeout is not None: + result["timeout"] = from_union([from_int, from_none], self.timeout) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SlashCommandModelPickerDialog: @@ -46540,6 +46904,7 @@ class RPC: mcp_oauth_authentication_state_changed_request: MCPOauthAuthenticationStateChangedRequest mcp_oauth_cancel_login_request: MCPOauthCancelLoginRequest mcp_oauth_cancel_login_result: MCPOauthCancelLoginResult + mcp_oauth_complete_request: MCPOauthCompleteRequest mcp_oauth_handle_pending_request: MCPOauthHandlePendingRequest mcp_oauth_handle_pending_result: MCPOauthHandlePendingResult mcp_oauth_login_grant_type: MCPGrantType @@ -46591,6 +46956,15 @@ class RPC: mcp_plan_value_category: MCPPlanValueCategory mcp_prepared_install: MCPPreparedInstall mcp_prepare_install_request: MCPPrepareInstallRequest + mcp_prompt: MCPPrompt + mcp_prompt_argument: MCPPromptArgument + mcp_prompt_icon: MCPPromptIcon + mcp_prompt_message: MCPPromptMessage + mcp_prompt_role: MCPPromptRole + mcp_prompts_get_request: MCPPromptsGetRequest + mcp_prompts_get_result: MCPPromptsGetResult + mcp_prompts_list_request: MCPPromptsListRequest + mcp_prompts_list_result: MCPPromptsListResult mcp_register_external_client_request: MCPRegisterExternalClientRequest mcp_reload_config: MCPReloadConfig mcp_reload_with_config_request: MCPReloadWithConfigRequest @@ -48027,6 +48401,7 @@ def from_dict(obj: Any) -> 'RPC': mcp_oauth_authentication_state_changed_request = MCPOauthAuthenticationStateChangedRequest.from_dict(obj.get("McpOauthAuthenticationStateChangedRequest")) mcp_oauth_cancel_login_request = MCPOauthCancelLoginRequest.from_dict(obj.get("McpOauthCancelLoginRequest")) mcp_oauth_cancel_login_result = MCPOauthCancelLoginResult.from_dict(obj.get("McpOauthCancelLoginResult")) + mcp_oauth_complete_request = MCPOauthCompleteRequest.from_dict(obj.get("McpOauthCompleteRequest")) mcp_oauth_handle_pending_request = MCPOauthHandlePendingRequest.from_dict(obj.get("McpOauthHandlePendingRequest")) mcp_oauth_handle_pending_result = MCPOauthHandlePendingResult.from_dict(obj.get("McpOauthHandlePendingResult")) mcp_oauth_login_grant_type = MCPGrantType(obj.get("McpOauthLoginGrantType")) @@ -48078,6 +48453,15 @@ def from_dict(obj: Any) -> 'RPC': mcp_plan_value_category = MCPPlanValueCategory(obj.get("McpPlanValueCategory")) mcp_prepared_install = MCPPreparedInstall.from_dict(obj.get("McpPreparedInstall")) mcp_prepare_install_request = MCPPrepareInstallRequest.from_dict(obj.get("McpPrepareInstallRequest")) + mcp_prompt = MCPPrompt.from_dict(obj.get("McpPrompt")) + mcp_prompt_argument = MCPPromptArgument.from_dict(obj.get("McpPromptArgument")) + mcp_prompt_icon = MCPPromptIcon.from_dict(obj.get("McpPromptIcon")) + mcp_prompt_message = MCPPromptMessage.from_dict(obj.get("McpPromptMessage")) + mcp_prompt_role = MCPPromptRole(obj.get("McpPromptRole")) + mcp_prompts_get_request = MCPPromptsGetRequest.from_dict(obj.get("McpPromptsGetRequest")) + mcp_prompts_get_result = MCPPromptsGetResult.from_dict(obj.get("McpPromptsGetResult")) + mcp_prompts_list_request = MCPPromptsListRequest.from_dict(obj.get("McpPromptsListRequest")) + mcp_prompts_list_result = MCPPromptsListResult.from_dict(obj.get("McpPromptsListResult")) mcp_register_external_client_request = MCPRegisterExternalClientRequest.from_dict(obj.get("McpRegisterExternalClientRequest")) mcp_reload_config = MCPReloadConfig.from_dict(obj.get("McpReloadConfig")) mcp_reload_with_config_request = MCPReloadWithConfigRequest.from_dict(obj.get("McpReloadWithConfigRequest")) @@ -48968,7 +49352,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, accepted_enqueue_command_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_kind, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, accounts_enumerate_request, accounts_get_request, accounts_set_request, account_status, 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_enumerate_query, auth_enumerate_value, auth_identity, auth_identity_metadata, auth_info, auth_info_type, auth_login_advance_request, auth_login_begin_request, auth_login_begun, auth_login_cancel_request, auth_login_result_dto, auth_login_result_status, auth_login_step, auth_read_query, auth_read_value, auth_status_dto, auth_validation_error, auth_validation_errors, auth_write, auth_write_result, autopilot_objective_credit_limit, autopilot_objective_get_state_result, autopilot_objective_state, autopilot_objective_status, 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_agent_plugin_candidate, catalog_agent_plugin_candidate_kind, catalog_agent_plugin_candidate_provenance, catalog_agent_plugin_compatibility_tag, catalog_agent_plugin_media_type, catalog_ai_skill_candidate, catalog_ai_skill_candidate_kind, catalog_ai_skill_candidate_provenance, catalog_ai_skill_installability, catalog_ai_skill_media_type, 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_kind, 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_plugin_repository_source, catalog_policy_rejected_error, catalog_resource_identity, catalog_resource_version, catalog_search_page, catalog_search_pagination, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_search_total_count_relation, catalog_selection_cancelled, catalog_selection_decision, catalog_selection_declined, catalog_selection_foreign, catalog_selection_invalid, catalog_selection_replayed, catalog_selection_request, catalog_selection_result, catalog_selection_selected, catalog_selection_stale, catalog_selection_timed_out, catalog_selection_wrong_kind, catalog_trust_eligibility, catalog_trust_provenance, catalog_trust_snapshot, catalog_trust_snapshot_absent, catalog_trust_snapshot_absent_status, catalog_trust_snapshot_current, catalog_trust_snapshot_current_status, catalog_trust_snapshot_downgraded, catalog_trust_snapshot_downgraded_status, catalog_trust_snapshot_malformed, catalog_trust_snapshot_malformed_status, catalog_trust_snapshot_revoked, catalog_trust_snapshot_revoked_status, catalog_trust_snapshot_schema_version, catalog_trust_snapshot_stale, catalog_trust_snapshot_stale_status, catalog_trust_snapshot_unsupported, catalog_trust_snapshot_unsupported_status, catalog_trust_source, catalog_trust_tier, 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, client_metadata, client_task_cancel_reason, client_task_cancel_request, client_task_cancel_result, 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, connector_account_request, connector_authorization_requirement, connector_authorization_scope, connector_availability, connector_capabilities, connector_catalog_entry, connector_catalog_result, connector_catalog_status, connector_connect_request, connector_connect_result, connector_continue_request, connector_disconnect_result, connector_mcp_status, connector_reconcile_request, connector_runtime_status, connector_session_account, connector_status, 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, diagnostic_cursor_status, diagnostic_entry, diagnostic_log_level, diagnostics_configuration, diagnostics_configure_request, diagnostic_severity, diagnostic_source, diagnostic_sources_configuration, diagnostics_read_request, diagnostics_read_result, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_hook, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, entra_token_acquire_request, entra_token_acquire_result, entra_token_interaction, env_auth_info, environment_capabilities, environment_kind, environments_delete_request, environments_delete_result, environments_get_request, environments_get_result, environments_list_request, environments_list_result, 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, 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_environment, 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_origin, hooks_discover_request, hooks_discover_result, hook_type, host_configuration, host_dispose_request, host_empty_result, host_environment_credentials, host_exited_notification, host_exit_reason, host_git_hub_environment_options, host_local_server_configuration, host_local_server_options, host_publish_session_request, host_publish_session_result, host_ready_request, host_register_session_request, host_session_create_callback, host_session_create_request, host_session_create_result, host_session_released_notification, host_session_release_request, host_start_request, host_start_result, installation_catalogue_identity, installation_confirmation_request, installation_confirmation_response, installation_decision, installation_review, 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, json_schema_response_format, 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, login_provider_kind, log_request, log_result, lsp_initialize_request, managed_mcp_server_config, managed_setting_meta, managed_settings_channel, managed_settings_compose_layer, managed_settings_compose_request, managed_settings_compose_result, managed_settings_diagnostic, managed_settings_diagnostic_severity, managed_settings_layer, managed_settings_meta, managed_settings_read_result, managed_settings_resolved_data, managed_settings_resolve_request, managed_settings_resolve_result, managed_settings_schema_result, managed_settings_validate_request, managed_settings_validate_result, managed_settings_values, 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_apply_install_request, mcp_apply_uninstall_request, 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_diagnostic_details, mcp_diagnostic_direction, mcp_diagnostic_kind, mcp_diagnostic_source_configuration, 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_installation_failure_reason, mcp_installation_input, mcp_installation_management_outcome, mcp_installation_management_result, mcp_installation_operation_request, mcp_installation_operation_status, mcp_installation_outcome, mcp_installation_remote_configuration, mcp_installation_result, mcp_installation_review, mcp_installation_secret, mcp_installation_secret_storage, mcp_installations_request, mcp_installation_state, mcp_installation_summary, 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_cancel_login_request, mcp_oauth_cancel_login_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_prepare_login_request, mcp_oauth_prepare_login_result, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_owned_oauth_login_status, 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_uninstall_request, mcp_plan_value_category, mcp_prepared_install, mcp_prepare_install_request, 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_server_ownership, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_source_file, mcp_source_plugin, mcp_source_ref, 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_uninstall_plan, 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, metadata_update_client_metadata_request, 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_provider_descriptor, model_provider_kind, model_provider_ref, model_set_allowed_models_request, model_set_allowed_models_result, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_auto_tier_request, model_switch_auto_tier_result, model_switch_auto_tier_status, 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_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_workflow, 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_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_workflow, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_approve_read_only_for_session, 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_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_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_workflow, 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_install_staging_mode, 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_append_mode, protocol_customize_mode, protocol_external_tool_defer, protocol_external_tool_definition, protocol_marker_section_override, protocol_replace_mode, protocol_section_override, protocol_static_section_action, protocol_static_section_override, protocol_system_message_append_config, protocol_system_message_config, protocol_system_message_customize_config, protocol_system_message_replace_config, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_descriptor, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_sync_request, provider_sync_result, provider_token_acquire_request, provider_token_acquire_result, provider_withdraw_request, provider_withdraw_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_append_steering_request, 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, queue_withdraw_message_request, queue_withdraw_message_result, register_event_interest_params, register_event_interest_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, response_format, 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_credentials_config, sandbox_disable_for_session_request, sandbox_disable_for_session_result, sandbox_enforcement_status, sandbox_grant_path_for_request_request, sandbox_grant_path_for_request_result, sandbox_host_capability, sandbox_host_capability_name, sandbox_host_support, sandbox_masked_env_var, sandbox_session_change, 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_mcp_oauth_cancel_login_request, session_mcp_oauth_cancel_login_result, session_mcp_oauth_prepare_login_request, session_mcp_oauth_prepare_login_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_disable_request, session_plugins_enable_request, session_plugins_install_request, session_plugins_marketplaces_refresh_request, 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_client_metadata_entry, 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_client_metadata_request, sessions_get_client_metadata_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_read_persisted_events_request, 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_workflow_pause_at_checkpoint_result, 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_apply_install_request, skill_apply_uninstall_request, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_installation_failure_reason, skill_installation_file_review, skill_installation_location, skill_installation_management_outcome, skill_installation_management_result, skill_installation_operation_request, skill_installation_operation_status, skill_installation_outcome, skill_installation_ownership_state, skill_installation_result, skill_installation_review, skill_installation_scope, skill_installation_session_state, skill_installation_source, skill_installations_request, skill_installation_summary, skill_install_plan, skill_list, skill_plan_install_request, skill_plan_uninstall_request, skill_provider_descriptor, skill_provider_list_request, skill_provider_list_result, skill_provider_read_request, skill_provider_read_result, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skill_set_enabled_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, skill_uninstall_plan, 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, system_message_block, task_agent_info, task_agent_progress, task_client_active_status, task_client_execution_mode, task_client_info, task_client_owner, task_client_owner_kind, task_client_owner_presence, task_client_progress, task_client_status, task_client_type, task_client_update, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_kind, 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_register_request, tasks_register_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_update_request, tasks_update_result, 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, unsupported_enqueue_command_result, 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, workflow_abort_request, workflow_ack_result, workflow_agent_options, workflow_agent_request, workflow_agent_result, workflow_agent_summary, workflow_cancel_request, workflow_current_phase, workflow_declared_limits, workflow_durable_operation, workflow_execute_request, workflow_execute_result, workflow_get_run_progress_request, workflow_get_run_request, workflow_journal_get_request, workflow_journal_get_result, workflow_journal_put_request, workflow_list_runs_request, workflow_list_runs_result, workflow_log_line, workflow_log_line_kind, workflow_log_request, workflow_pause_checkpoint_action, workflow_pause_checkpoint_request, workflow_pause_checkpoint_result, workflow_pause_info, workflow_pause_request, workflow_phase_observation, workflow_phase_status, workflow_progress_line, workflow_progress_page, workflow_resume_request, workflow_resume_result, workflow_run_consumed, workflow_run_detail, workflow_run_failure, workflow_run_failure_kind, workflow_run_limits, workflow_run_options, workflow_run_request, workflow_run_result, workflow_run_status, workflow_run_summary, workflow_run_terminal, workflow_tool_resume_request, workflow_tool_run_options, workflow_tool_run_request, 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_directory_request, 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_remove_path_request, workspaces_rename_path_request, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_stat_file_request, workspaces_stat_file_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, connector_session_account_result, session_auth_info_result, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) + return RPC(abort_request, abort_result, accepted_enqueue_command_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_kind, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, accounts_enumerate_request, accounts_get_request, accounts_set_request, account_status, 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_enumerate_query, auth_enumerate_value, auth_identity, auth_identity_metadata, auth_info, auth_info_type, auth_login_advance_request, auth_login_begin_request, auth_login_begun, auth_login_cancel_request, auth_login_result_dto, auth_login_result_status, auth_login_step, auth_read_query, auth_read_value, auth_status_dto, auth_validation_error, auth_validation_errors, auth_write, auth_write_result, autopilot_objective_credit_limit, autopilot_objective_get_state_result, autopilot_objective_state, autopilot_objective_status, 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_agent_plugin_candidate, catalog_agent_plugin_candidate_kind, catalog_agent_plugin_candidate_provenance, catalog_agent_plugin_compatibility_tag, catalog_agent_plugin_media_type, catalog_ai_skill_candidate, catalog_ai_skill_candidate_kind, catalog_ai_skill_candidate_provenance, catalog_ai_skill_installability, catalog_ai_skill_media_type, 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_kind, 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_plugin_repository_source, catalog_policy_rejected_error, catalog_resource_identity, catalog_resource_version, catalog_search_page, catalog_search_pagination, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_search_total_count_relation, catalog_selection_cancelled, catalog_selection_decision, catalog_selection_declined, catalog_selection_foreign, catalog_selection_invalid, catalog_selection_replayed, catalog_selection_request, catalog_selection_result, catalog_selection_selected, catalog_selection_stale, catalog_selection_timed_out, catalog_selection_wrong_kind, catalog_trust_eligibility, catalog_trust_provenance, catalog_trust_snapshot, catalog_trust_snapshot_absent, catalog_trust_snapshot_absent_status, catalog_trust_snapshot_current, catalog_trust_snapshot_current_status, catalog_trust_snapshot_downgraded, catalog_trust_snapshot_downgraded_status, catalog_trust_snapshot_malformed, catalog_trust_snapshot_malformed_status, catalog_trust_snapshot_revoked, catalog_trust_snapshot_revoked_status, catalog_trust_snapshot_schema_version, catalog_trust_snapshot_stale, catalog_trust_snapshot_stale_status, catalog_trust_snapshot_unsupported, catalog_trust_snapshot_unsupported_status, catalog_trust_source, catalog_trust_tier, 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, client_metadata, client_task_cancel_reason, client_task_cancel_request, client_task_cancel_result, 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, connector_account_request, connector_authorization_requirement, connector_authorization_scope, connector_availability, connector_capabilities, connector_catalog_entry, connector_catalog_result, connector_catalog_status, connector_connect_request, connector_connect_result, connector_continue_request, connector_disconnect_result, connector_mcp_status, connector_reconcile_request, connector_runtime_status, connector_session_account, connector_status, 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, diagnostic_cursor_status, diagnostic_entry, diagnostic_log_level, diagnostics_configuration, diagnostics_configure_request, diagnostic_severity, diagnostic_source, diagnostic_sources_configuration, diagnostics_read_request, diagnostics_read_result, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_hook, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, entra_token_acquire_request, entra_token_acquire_result, entra_token_interaction, env_auth_info, environment_capabilities, environment_kind, environments_delete_request, environments_delete_result, environments_get_request, environments_get_result, environments_list_request, environments_list_result, 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, 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_environment, 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_origin, hooks_discover_request, hooks_discover_result, hook_type, host_configuration, host_dispose_request, host_empty_result, host_environment_credentials, host_exited_notification, host_exit_reason, host_git_hub_environment_options, host_local_server_configuration, host_local_server_options, host_publish_session_request, host_publish_session_result, host_ready_request, host_register_session_request, host_session_create_callback, host_session_create_request, host_session_create_result, host_session_released_notification, host_session_release_request, host_start_request, host_start_result, installation_catalogue_identity, installation_confirmation_request, installation_confirmation_response, installation_decision, installation_review, 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, json_schema_response_format, 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, login_provider_kind, log_request, log_result, lsp_initialize_request, managed_mcp_server_config, managed_setting_meta, managed_settings_channel, managed_settings_compose_layer, managed_settings_compose_request, managed_settings_compose_result, managed_settings_diagnostic, managed_settings_diagnostic_severity, managed_settings_layer, managed_settings_meta, managed_settings_read_result, managed_settings_resolved_data, managed_settings_resolve_request, managed_settings_resolve_result, managed_settings_schema_result, managed_settings_validate_request, managed_settings_validate_result, managed_settings_values, 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_apply_install_request, mcp_apply_uninstall_request, 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_diagnostic_details, mcp_diagnostic_direction, mcp_diagnostic_kind, mcp_diagnostic_source_configuration, 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_installation_failure_reason, mcp_installation_input, mcp_installation_management_outcome, mcp_installation_management_result, mcp_installation_operation_request, mcp_installation_operation_status, mcp_installation_outcome, mcp_installation_remote_configuration, mcp_installation_result, mcp_installation_review, mcp_installation_secret, mcp_installation_secret_storage, mcp_installations_request, mcp_installation_state, mcp_installation_summary, 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_cancel_login_request, mcp_oauth_cancel_login_result, mcp_oauth_complete_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_prepare_login_request, mcp_oauth_prepare_login_result, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_owned_oauth_login_status, 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_uninstall_request, mcp_plan_value_category, mcp_prepared_install, mcp_prepare_install_request, mcp_prompt, mcp_prompt_argument, mcp_prompt_icon, mcp_prompt_message, mcp_prompt_role, mcp_prompts_get_request, mcp_prompts_get_result, mcp_prompts_list_request, mcp_prompts_list_result, 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_server_ownership, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_source_file, mcp_source_plugin, mcp_source_ref, 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_uninstall_plan, 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, metadata_update_client_metadata_request, 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_provider_descriptor, model_provider_kind, model_provider_ref, model_set_allowed_models_request, model_set_allowed_models_result, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_auto_tier_request, model_switch_auto_tier_result, model_switch_auto_tier_status, 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_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_workflow, 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_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_workflow, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_approve_read_only_for_session, 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_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_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_workflow, 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_install_staging_mode, 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_append_mode, protocol_customize_mode, protocol_external_tool_defer, protocol_external_tool_definition, protocol_marker_section_override, protocol_replace_mode, protocol_section_override, protocol_static_section_action, protocol_static_section_override, protocol_system_message_append_config, protocol_system_message_config, protocol_system_message_customize_config, protocol_system_message_replace_config, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_descriptor, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_sync_request, provider_sync_result, provider_token_acquire_request, provider_token_acquire_result, provider_withdraw_request, provider_withdraw_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_append_steering_request, 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, queue_withdraw_message_request, queue_withdraw_message_result, register_event_interest_params, register_event_interest_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, response_format, 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_credentials_config, sandbox_disable_for_session_request, sandbox_disable_for_session_result, sandbox_enforcement_status, sandbox_grant_path_for_request_request, sandbox_grant_path_for_request_result, sandbox_host_capability, sandbox_host_capability_name, sandbox_host_support, sandbox_masked_env_var, sandbox_session_change, 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_mcp_oauth_cancel_login_request, session_mcp_oauth_cancel_login_result, session_mcp_oauth_prepare_login_request, session_mcp_oauth_prepare_login_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_disable_request, session_plugins_enable_request, session_plugins_install_request, session_plugins_marketplaces_refresh_request, 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_client_metadata_entry, 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_client_metadata_request, sessions_get_client_metadata_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_read_persisted_events_request, 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_workflow_pause_at_checkpoint_result, 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_apply_install_request, skill_apply_uninstall_request, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_installation_failure_reason, skill_installation_file_review, skill_installation_location, skill_installation_management_outcome, skill_installation_management_result, skill_installation_operation_request, skill_installation_operation_status, skill_installation_outcome, skill_installation_ownership_state, skill_installation_result, skill_installation_review, skill_installation_scope, skill_installation_session_state, skill_installation_source, skill_installations_request, skill_installation_summary, skill_install_plan, skill_list, skill_plan_install_request, skill_plan_uninstall_request, skill_provider_descriptor, skill_provider_list_request, skill_provider_list_result, skill_provider_read_request, skill_provider_read_result, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skill_set_enabled_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, skill_uninstall_plan, 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, system_message_block, task_agent_info, task_agent_progress, task_client_active_status, task_client_execution_mode, task_client_info, task_client_owner, task_client_owner_kind, task_client_owner_presence, task_client_progress, task_client_status, task_client_type, task_client_update, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_kind, 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_register_request, tasks_register_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_update_request, tasks_update_result, 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, unsupported_enqueue_command_result, 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, workflow_abort_request, workflow_ack_result, workflow_agent_options, workflow_agent_request, workflow_agent_result, workflow_agent_summary, workflow_cancel_request, workflow_current_phase, workflow_declared_limits, workflow_durable_operation, workflow_execute_request, workflow_execute_result, workflow_get_run_progress_request, workflow_get_run_request, workflow_journal_get_request, workflow_journal_get_result, workflow_journal_put_request, workflow_list_runs_request, workflow_list_runs_result, workflow_log_line, workflow_log_line_kind, workflow_log_request, workflow_pause_checkpoint_action, workflow_pause_checkpoint_request, workflow_pause_checkpoint_result, workflow_pause_info, workflow_pause_request, workflow_phase_observation, workflow_phase_status, workflow_progress_line, workflow_progress_page, workflow_resume_request, workflow_resume_result, workflow_run_consumed, workflow_run_detail, workflow_run_failure, workflow_run_failure_kind, workflow_run_limits, workflow_run_options, workflow_run_request, workflow_run_result, workflow_run_status, workflow_run_summary, workflow_run_terminal, workflow_tool_resume_request, workflow_tool_run_options, workflow_tool_run_request, 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_directory_request, 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_remove_path_request, workspaces_rename_path_request, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_stat_file_request, workspaces_stat_file_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, connector_session_account_result, session_auth_info_result, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -49514,6 +49898,7 @@ def to_dict(self) -> dict: result["McpOauthAuthenticationStateChangedRequest"] = to_class(MCPOauthAuthenticationStateChangedRequest, self.mcp_oauth_authentication_state_changed_request) result["McpOauthCancelLoginRequest"] = to_class(MCPOauthCancelLoginRequest, self.mcp_oauth_cancel_login_request) result["McpOauthCancelLoginResult"] = to_class(MCPOauthCancelLoginResult, self.mcp_oauth_cancel_login_result) + result["McpOauthCompleteRequest"] = to_class(MCPOauthCompleteRequest, self.mcp_oauth_complete_request) result["McpOauthHandlePendingRequest"] = to_class(MCPOauthHandlePendingRequest, self.mcp_oauth_handle_pending_request) result["McpOauthHandlePendingResult"] = to_class(MCPOauthHandlePendingResult, self.mcp_oauth_handle_pending_result) result["McpOauthLoginGrantType"] = to_enum(MCPGrantType, self.mcp_oauth_login_grant_type) @@ -49565,6 +49950,15 @@ def to_dict(self) -> dict: result["McpPlanValueCategory"] = to_enum(MCPPlanValueCategory, self.mcp_plan_value_category) result["McpPreparedInstall"] = to_class(MCPPreparedInstall, self.mcp_prepared_install) result["McpPrepareInstallRequest"] = to_class(MCPPrepareInstallRequest, self.mcp_prepare_install_request) + result["McpPrompt"] = to_class(MCPPrompt, self.mcp_prompt) + result["McpPromptArgument"] = to_class(MCPPromptArgument, self.mcp_prompt_argument) + result["McpPromptIcon"] = to_class(MCPPromptIcon, self.mcp_prompt_icon) + result["McpPromptMessage"] = to_class(MCPPromptMessage, self.mcp_prompt_message) + result["McpPromptRole"] = to_enum(MCPPromptRole, self.mcp_prompt_role) + result["McpPromptsGetRequest"] = to_class(MCPPromptsGetRequest, self.mcp_prompts_get_request) + result["McpPromptsGetResult"] = to_class(MCPPromptsGetResult, self.mcp_prompts_get_result) + result["McpPromptsListRequest"] = to_class(MCPPromptsListRequest, self.mcp_prompts_list_request) + result["McpPromptsListResult"] = to_class(MCPPromptsListResult, self.mcp_prompts_list_result) result["McpRegisterExternalClientRequest"] = to_class(MCPRegisterExternalClientRequest, self.mcp_register_external_client_request) result["McpReloadConfig"] = to_class(MCPReloadConfig, self.mcp_reload_config) result["McpReloadWithConfigRequest"] = to_class(MCPReloadWithConfigRequest, self.mcp_reload_with_config_request) @@ -52517,11 +52911,17 @@ async def prepare_login(self, params: SessionMCPOauthPrepareLoginRequest, *, tim return SessionMCPOauthPrepareLoginResult.from_dict(await self._client.request("session.mcp.oauth.prepareLogin", params_dict, **_timeout_kwargs(timeout))) async def login(self, params: MCPOauthLoginRequest, *, timeout: float | None = None) -> MCPOauthLoginResult: - "Starts OAuth authentication for a remote MCP server. Owned servers require the original one-use prepareLogin handle and exact installation ID; manual servers retain the existing direct login behaviour.\n\nArgs:\n params: Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection.\n\nReturns:\n OAuth authorization URL the caller should open, or empty when cached tokens already authenticated the server." + "Starts OAuth authentication for a remote MCP server. Owned servers require the original one-use prepareLogin handle and exact installation ID; manual servers retain the existing direct login behaviour.\n\nArgs:\n params: Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection.\n\nReturns:\n OAuth authorization URL the caller should open, or empty when cached tokens already authenticated the server." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id return MCPOauthLoginResult.from_dict(await self._client.request("session.mcp.oauth.login", params_dict, **_timeout_kwargs(timeout))) + async def complete(self, params: MCPOauthCompleteRequest, *, timeout: float | None = None) -> None: + "Completes a runtime-managed MCP OAuth login after the authorization server redirects to a host-managed callback URL.\n\nArgs:\n params: Host-delivered callback for a runtime-managed MCP OAuth login." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + await self._client.request("session.mcp.oauth.complete", params_dict, **_timeout_kwargs(timeout)) + async def probe(self, params: MCPOauthProbeRequest, *, timeout: float | None = None) -> MCPOauthProbeResult: "Passively probes a configured remote MCP server to classify whether OAuth is required or a cached/override token is accepted. Does not start OAuth, emit pending OAuth requests, or mutate MCP connection state.\n\nArgs:\n params: Remote MCP server name for a passive OAuth status probe.\n\nReturns:\n Passive MCP OAuth probe result. `authenticated` means the server accepted the probe request while an OAuth-origin access token was attached; it does not prove the server required or independently validated that token. The probe does not make a second unauthenticated request. Failed is an expected probe-domain outcome; JSON-RPC errors are reserved for API-call failures." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} @@ -52620,6 +53020,25 @@ async def list_templates(self, params: MCPResourcesListTemplatesRequest, *, time return MCPResourcesListTemplatesResult.from_dict(await self._client.request("session.mcp.resources.listTemplates", params_dict, **_timeout_kwargs(timeout))) +# Experimental: this API group is experimental and may change or be removed. +class McpPromptsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self, params: MCPPromptsListRequest, *, timeout: float | None = None) -> MCPPromptsListResult: + "Enumerate one page of prompts a connected MCP server exposes (proxies MCP `prompts/list`). Pass `cursor` to continue from a prior result's `nextCursor`.\n\nArgs:\n params: MCP server whose prompts to enumerate.\n\nReturns:\n One page of prompts advertised by the named MCP server." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return MCPPromptsListResult.from_dict(await self._client.request("session.mcp.prompts.list", params_dict, **_timeout_kwargs(timeout))) + + async def get(self, params: MCPPromptsGetRequest, *, timeout: float | None = None) -> MCPPromptsGetResult: + "Get a prompt's messages from a connected MCP server (proxies MCP `prompts/get`). Content is preserved as opaque JSON. Does not send messages to the model, execute tools, or fetch referenced resources.\n\nArgs:\n params: MCP server, prompt name, and optional string-valued arguments.\n\nReturns:\n Prompt messages returned by the MCP server without sending them to the model." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return MCPPromptsGetResult.from_dict(await self._client.request("session.mcp.prompts.get", params_dict, **_timeout_kwargs(timeout))) + + # Experimental: this API group is experimental and may change or be removed. class McpApi: def __init__(self, client: "JsonRpcClient", session_id: str): @@ -52629,6 +53048,7 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.headers = McpHeadersApi(client, session_id) self.apps = McpAppsApi(client, session_id) self.resources = McpResourcesApi(client, session_id) + self.prompts = McpPromptsApi(client, session_id) async def list(self, *, timeout: float | None = None) -> MCPServerList: "Lists MCP servers configured for the session, their connection status, and host-level state. The host-level state (disabled/filtered servers, failed/needs-auth/pending connections, mcp3p policy, full config) is empty/zero when no MCP host has been initialized for the session.\n\nReturns:\n MCP servers configured for the session, with their connection status and host-level state." @@ -53390,7 +53810,7 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._session_id = session_id async def exec(self, params: ShellExecRequest, *, timeout: float | None = None) -> ShellExecResult: - "Starts a shell command and streams output through session notifications. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via \"shell.kill\", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via \"setsid\") leaves the signalled group, so either can leave a background process running.\n\nArgs:\n params: Shell command to run, with optional working directory and timeout in milliseconds.\n\nReturns:\n Identifier of the spawned process, used to correlate streamed output and exit notifications." + "Starts a shell command, returning an RPC error if it cannot be spawned. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via \"shell.kill\", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via \"setsid\") leaves the signalled group, so either can leave a background process running.\n\nArgs:\n params: Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error.\n\nReturns:\n Identifier of the spawned shell process, usable with shell.kill while the process is running." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id return ShellExecResult.from_dict(await self._client.request("session.shell.exec", params_dict, **_timeout_kwargs(timeout))) @@ -55061,6 +55481,7 @@ async def handle_installations_confirm(params: dict) -> dict | None: "MCPOauthAuthenticationStateChangedRequest", "MCPOauthCancelLoginRequest", "MCPOauthCancelLoginResult", + "MCPOauthCompleteRequest", "MCPOauthHandlePendingRequest", "MCPOauthHandlePendingResult", "MCPOauthLoginRequest", @@ -55118,6 +55539,15 @@ async def handle_installations_confirm(params: dict) -> dict | None: "MCPPlanValueCategory", "MCPPrepareInstallRequest", "MCPPreparedInstall", + "MCPPrompt", + "MCPPromptArgument", + "MCPPromptIcon", + "MCPPromptMessage", + "MCPPromptRole", + "MCPPromptsGetRequest", + "MCPPromptsGetResult", + "MCPPromptsListRequest", + "MCPPromptsListResult", "MCPRegisterExternalClientRequest", "MCPReloadConfig", "MCPReloadWithConfigRequest", @@ -55224,6 +55654,7 @@ async def handle_installations_confirm(params: dict) -> dict | None: "McpPlanScalarValueType", "McpPlanSecretReference", "McpPlanTransportChoice", + "McpPromptsApi", "McpResourcesApi", "McpSafeForTelemetry", "McpServerAuthConfig", diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index eb0364d9ea..1dd9f8e066 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -1743,6 +1743,7 @@ class SessionAutoModeResolvedData: reasoning_bucket: AutoModeResolvedReasoningBucket | None = None router_latency_ms: float | None = None routing_method: str | None = None + selection_reason: str | None = None sticky_override: bool | None = None @staticmethod @@ -1762,6 +1763,7 @@ def from_dict(obj: Any) -> "SessionAutoModeResolvedData": reasoning_bucket = from_union([from_none, lambda x: parse_enum(AutoModeResolvedReasoningBucket, x)], obj.get("reasoningBucket")) router_latency_ms = from_union([from_none, from_float], obj.get("routerLatencyMs")) routing_method = from_union([from_none, from_str], obj.get("routingMethod")) + selection_reason = from_union([from_none, from_str], obj.get("selectionReason")) sticky_override = from_union([from_none, from_bool], obj.get("stickyOverride")) return SessionAutoModeResolvedData( chosen_model=chosen_model, @@ -1778,6 +1780,7 @@ def from_dict(obj: Any) -> "SessionAutoModeResolvedData": reasoning_bucket=reasoning_bucket, router_latency_ms=router_latency_ms, routing_method=routing_method, + selection_reason=selection_reason, sticky_override=sticky_override, ) @@ -1810,6 +1813,8 @@ def to_dict(self) -> dict: result["routerLatencyMs"] = from_union([from_none, to_float], self.router_latency_ms) if self.routing_method is not None: result["routingMethod"] = from_union([from_none, from_str], self.routing_method) + if self.selection_reason is not None: + result["selectionReason"] = from_union([from_none, from_str], self.selection_reason) if self.sticky_override is not None: result["stickyOverride"] = from_union([from_none, from_bool], self.sticky_override) return result @@ -8213,6 +8218,8 @@ class PromptCacheBreakData: # Internal: this field is an internal SDK API and is not part of the public surface. _tools_redefined: list[str] | None = None # Internal: this field is an internal SDK API and is not part of the public surface. + _tools_redefined_parts: list[str] | None = None + # Internal: this field is an internal SDK API and is not part of the public surface. _tools_redefined_raw: list[str] | None = None # Internal: this field is an internal SDK API and is not part of the public surface. _tools_removed: list[str] | None = None @@ -8243,6 +8250,7 @@ def from_dict(obj: Any) -> "PromptCacheBreakData": _tools_added = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("toolsAdded")) _tools_added_raw = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("toolsAddedRaw")) _tools_redefined = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("toolsRedefined")) + _tools_redefined_parts = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("toolsRedefinedParts")) _tools_redefined_raw = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("toolsRedefinedRaw")) _tools_removed = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("toolsRemoved")) _tools_removed_raw = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("toolsRemovedRaw")) @@ -8267,6 +8275,7 @@ def from_dict(obj: Any) -> "PromptCacheBreakData": _tools_added=_tools_added, _tools_added_raw=_tools_added_raw, _tools_redefined=_tools_redefined, + _tools_redefined_parts=_tools_redefined_parts, _tools_redefined_raw=_tools_redefined_raw, _tools_removed=_tools_removed, _tools_removed_raw=_tools_removed_raw, @@ -8307,6 +8316,8 @@ def to_dict(self) -> dict: result["toolsAddedRaw"] = from_union([from_none, lambda x: from_list(from_str, x)], self._tools_added_raw) if self._tools_redefined is not None: result["toolsRedefined"] = from_union([from_none, lambda x: from_list(from_str, x)], self._tools_redefined) + if self._tools_redefined_parts is not None: + result["toolsRedefinedParts"] = from_union([from_none, lambda x: from_list(from_str, x)], self._tools_redefined_parts) if self._tools_redefined_raw is not None: result["toolsRedefinedRaw"] = from_union([from_none, lambda x: from_list(from_str, x)], self._tools_redefined_raw) if self._tools_removed is not None: @@ -12434,21 +12445,27 @@ class ToolExecutionProgressData: "Tool execution progress notification with status message" progress_message: str tool_call_id: str + # Experimental: this field is part of an experimental API and may change or be removed. + structured_content: Any = None @staticmethod def from_dict(obj: Any) -> "ToolExecutionProgressData": assert isinstance(obj, dict) progress_message = from_str(obj.get("progressMessage")) tool_call_id = from_str(obj.get("toolCallId")) + structured_content = obj.get("structuredContent") return ToolExecutionProgressData( progress_message=progress_message, tool_call_id=tool_call_id, + structured_content=structured_content, ) def to_dict(self) -> dict: result: dict = {} result["progressMessage"] = from_str(self.progress_message) result["toolCallId"] = from_str(self.tool_call_id) + if self.structured_content is not None: + result["structuredContent"] = self.structured_content return result diff --git a/python/test_mcp_prompts_generated.py b/python/test_mcp_prompts_generated.py new file mode 100644 index 0000000000..2c62c4c231 --- /dev/null +++ b/python/test_mcp_prompts_generated.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. + +"""Generated prompt RPC contracts; real MCP behavior is covered by Node and .NET E2Es.""" + +import json +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from copilot.rpc import MCPPromptsGetRequest, MCPPromptsListRequest, SessionRpc + + +@pytest.fixture +def prompt_fixtures(): + path = Path(__file__).parent.parent / "test" / "harness" / "mcp-prompt-fixtures.json" + return json.loads(path.read_text(encoding="utf-8")) + + +@pytest.mark.asyncio +async def test_prompt_list_dispatch_and_optional_fields(prompt_fixtures): + client = AsyncMock() + api = SessionRpc(client, "bound-session") + client.request.return_value = prompt_fixtures["firstPage"] + + first = await api.mcp.prompts.list(MCPPromptsListRequest(server_name="fixture")) + + client.request.assert_awaited_once_with( + "session.mcp.prompts.list", {"sessionId": "bound-session", "serverName": "fixture"} + ) + assert first.to_dict() == prompt_fixtures["firstPage"] + assert [argument.required for argument in first.prompts[0].arguments] == [True, False, None] + + client.request.reset_mock() + client.request.return_value = prompt_fixtures["secondPage"] + second = await api.mcp.prompts.list( + MCPPromptsListRequest(server_name="fixture", cursor=first.next_cursor) + ) + client.request.assert_awaited_once_with( + "session.mcp.prompts.list", + {"sessionId": "bound-session", "serverName": "fixture", "cursor": first.next_cursor}, + ) + assert second.to_dict() == prompt_fixtures["secondPage"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("arguments", [None, {}, {"topic": "日本語", "style": ""}]) +async def test_prompt_get_dispatch_and_opaque_json(prompt_fixtures, arguments): + client = AsyncMock() + client.request.return_value = prompt_fixtures["richPrompt"] + api = SessionRpc(client, "bound-session") + + result = await api.mcp.prompts.get( + MCPPromptsGetRequest(server_name="fixture", prompt_name="rich", arguments=arguments) + ) + + expected = {"sessionId": "bound-session", "serverName": "fixture", "promptName": "rich"} + if arguments is not None: + expected["arguments"] = arguments + client.request.assert_awaited_once_with("session.mcp.prompts.get", expected) + assert result.to_dict() == prompt_fixtures["richPrompt"] + + +@pytest.mark.asyncio +async def test_prompt_get_propagates_rpc_error(): + client = AsyncMock() + client.request.side_effect = RuntimeError("Missing required argument: topic") + + with pytest.raises(RuntimeError, match="Missing required argument: topic"): + await SessionRpc(client, "bound-session").mcp.prompts.get( + MCPPromptsGetRequest(server_name="fixture", prompt_name="rich") + ) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index b72df54291..a89aa3df4e 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -566,6 +566,8 @@ pub mod rpc_methods { pub const SESSION_MCP_OAUTH_PREPARELOGIN: &str = "session.mcp.oauth.prepareLogin"; /// `session.mcp.oauth.login` pub const SESSION_MCP_OAUTH_LOGIN: &str = "session.mcp.oauth.login"; + /// `session.mcp.oauth.complete` + pub const SESSION_MCP_OAUTH_COMPLETE: &str = "session.mcp.oauth.complete"; /// `session.mcp.oauth.probe` pub const SESSION_MCP_OAUTH_PROBE: &str = "session.mcp.oauth.probe"; /// `session.mcp.oauth.cancelLogin` @@ -593,6 +595,10 @@ pub mod rpc_methods { pub const SESSION_MCP_RESOURCES_LIST: &str = "session.mcp.resources.list"; /// `session.mcp.resources.listTemplates` pub const SESSION_MCP_RESOURCES_LISTTEMPLATES: &str = "session.mcp.resources.listTemplates"; + /// `session.mcp.prompts.list` + pub const SESSION_MCP_PROMPTS_LIST: &str = "session.mcp.prompts.list"; + /// `session.mcp.prompts.get` + pub const SESSION_MCP_PROMPTS_GET: &str = "session.mcp.prompts.get"; /// `session.diagnostics.configure` pub const SESSION_DIAGNOSTICS_CONFIGURE: &str = "session.diagnostics.configure"; /// `session.diagnostics.read` @@ -11645,6 +11651,23 @@ pub struct McpOauthCancelLoginResult { pub cancelled: bool, } +/// Host-delivered callback for a runtime-managed MCP OAuth login. +/// +///

+/// +/// **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 McpOauthCompleteRequest { + /// Opaque identifier returned by session.mcp.oauth.login for the pending external callback. + pub authorization_id: String, + /// Full externally visible HTTPS callback URL received by the host, including the authorization response query parameters. Applications behind a reverse proxy must reconstruct the public URL rather than passing an internal proxy URL. + pub callback_url: String, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpOauthPendingRequestResponseToken { @@ -11699,7 +11722,7 @@ pub struct McpOauthHandlePendingResult { pub success: bool, } -/// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. +/// Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. /// ///
/// @@ -11752,6 +11775,8 @@ pub struct McpOauthLoginOptions { #[serde(flatten)] legacy: McpOauthLoginRequest, #[serde(skip_serializing_if = "Option::is_none")] + redirect_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] login_id: Option, #[serde(skip_serializing_if = "Option::is_none")] expected_installation_id: Option, @@ -11771,6 +11796,7 @@ impl McpOauthLoginOptions { public_client: None, grant_type: None, }, + redirect_uri: None, login_id: None, expected_installation_id: None, } @@ -11818,6 +11844,12 @@ impl McpOauthLoginOptions { self } + /// Optional externally visible HTTPS redirect URI for a host-managed callback endpoint. When supplied, the runtime still owns discovery, PKCE, token exchange, persistence, and reconnect, but does not bind a loopback listener or terminate HTTPS. The URI must not contain query parameters or a fragment and must be registered for the selected CIMD, DCR, or static OAuth client. + pub fn redirect_uri(mut self, value: impl Into) -> Self { + self.redirect_uri = Some(value.into()); + self + } + /// Required for owned login. Consumes the exact prepareLogin handle once. /// Set forceReauth and display options during preparation, not consumption. pub fn login_id(mut self, value: impl Into) -> Self { @@ -11843,7 +11875,10 @@ impl McpOauthLoginOptions { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpOauthLoginResult { - /// URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. When present, the runtime starts the callback listener before returning and continues the flow in the background; completion is signaled via session.mcp_server_status_changed. + /// Opaque authorization identifier returned only for a host-managed redirect URI. The runtime also sends it as the OAuth state value, so the callback endpoint can read state and pass it with the full callback URL to session.mcp.oauth.complete. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_id: Option, + /// URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. For the default loopback flow, the runtime starts its listener before returning. With redirectUri, the host receives the callback and completes it through session.mcp.oauth.complete. The runtime continues the flow in the background and signals completion via session.mcp_server_status_changed. #[serde(skip_serializing_if = "Option::is_none")] pub authorization_url: Option, /// Runtime-issued owned flow identity; never a server name or installation operation ID. @@ -12299,6 +12334,202 @@ pub struct McpPrepareInstallRequest { pub source: McpServerCardReference, } +/// An argument accepted by an MCP prompt. +/// +///
+/// +/// **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 McpPromptArgument { + /// Argument-level metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard argument fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Description of the argument + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Name of the argument + pub name: String, + /// Whether the argument is required; omission is distinct from false + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, +} + +/// An MCP prompt icon with standard size hints and preserved non-standard fields. +/// +///
+/// +/// **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 McpPromptIcon { + /// Server-provided non-standard icon fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Icon MIME type, when known + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Icon sizes, such as `48x48` or `any` + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option>, + /// Icon URI + pub src: String, + /// Theme hint for this icon + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +/// An MCP prompt descriptor. Server-provided non-standard fields are exposed under `additionalProperties`. +/// +///
+/// +/// **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 McpPrompt { + /// Prompt-level metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard descriptor fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Arguments accepted by the prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option>, + /// Description of what this prompt provides + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Icons associated with this prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// The programmatic name of the prompt + pub name: String, + /// Human-readable display title + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// An MCP prompt message with opaque JSON content preserved without flattening or content-type filtering. +/// +///
+/// +/// **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 McpPromptMessage { + /// Message-level metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard message fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// The original MCP content block, including nested metadata and unfamiliar content types + pub content: serde_json::Value, + /// The role of the message sender + pub role: McpPromptRole, +} + +/// MCP server, prompt name, and optional string-valued arguments. +/// +///
+/// +/// **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 McpPromptsGetRequest { + /// String-valued arguments to pass to the prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option>, + /// The programmatic name of the prompt + pub prompt_name: String, + /// Name of the MCP server hosting the prompt + pub server_name: String, +} + +/// Prompt messages returned by the MCP server without sending them to the model. +/// +///
+/// +/// **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 McpPromptsGetResult { + /// MCP result metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard result fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Description of the prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Ordered prompt messages + pub messages: Vec, +} + +/// MCP server whose prompts to enumerate. +/// +///
+/// +/// **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 McpPromptsListRequest { + /// Opaque MCP pagination cursor from a prior `nextCursor` value + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Name of the MCP server whose prompts to enumerate + pub server_name: String, +} + +/// One page of prompts advertised by the named MCP server. +/// +///
+/// +/// **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 McpPromptsListResult { + /// MCP result metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard result fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Opaque cursor for the next page, if the server has more prompts + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Prompts advertised by the server + pub prompts: Vec, +} + /// Registration parameters for an external MCP client. /// ///
@@ -22965,7 +23196,7 @@ pub struct ShellCancelUserRequestedRequest { pub request_id: RequestId, } -/// Shell command to run, with optional working directory and timeout in milliseconds. +/// Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error. /// ///
/// @@ -22986,7 +23217,7 @@ pub struct ShellExecRequest { pub timeout: Option, } -/// Identifier of the spawned process, used to correlate streamed output and exit notifications. +/// Identifier of the spawned shell process, usable with shell.kill while the process is running. /// ///
/// @@ -22997,7 +23228,7 @@ pub struct ShellExecRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ShellExecResult { - /// Unique identifier for tracking streamed output + /// Identifier usable with shell.kill while the process is running pub process_id: String, } @@ -31447,7 +31678,10 @@ pub struct SessionMcpOauthPrepareLoginResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionMcpOauthLoginResult { - /// URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. When present, the runtime starts the callback listener before returning and continues the flow in the background; completion is signaled via session.mcp_server_status_changed. + /// Opaque authorization identifier returned only for a host-managed redirect URI. The runtime also sends it as the OAuth state value, so the callback endpoint can read state and pass it with the full callback URL to session.mcp.oauth.complete. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_id: Option, + /// URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. For the default loopback flow, the runtime starts its listener before returning. With redirectUri, the host receives the callback and completes it through session.mcp.oauth.complete. The runtime continues the flow in the background and signals completion via session.mcp_server_status_changed. #[serde(skip_serializing_if = "Option::is_none")] pub authorization_url: Option, /// Runtime-issued owned flow identity; never a server name or installation operation ID. @@ -31648,6 +31882,54 @@ pub struct SessionMcpResourcesListTemplatesResult { pub resource_templates: Vec, } +/// One page of prompts advertised by the named MCP server. +/// +///
+/// +/// **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 SessionMcpPromptsListResult { + /// MCP result metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard result fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Opaque cursor for the next page, if the server has more prompts + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Prompts advertised by the server + pub prompts: Vec, +} + +/// Prompt messages returned by the MCP server without sending them to the model. +/// +///
+/// +/// **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 SessionMcpPromptsGetResult { + /// MCP result metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard result fields + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Description of the prompt + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Ordered prompt messages + pub messages: Vec, +} + /// Per-source session diagnostics configuration. /// ///
@@ -33577,7 +33859,7 @@ pub struct SessionContentExclusionCheckPathsResult { pub checks: Vec, } -/// Identifier of the spawned process, used to correlate streamed output and exit notifications. +/// Identifier of the spawned shell process, usable with shell.kill while the process is running. /// ///
/// @@ -33588,7 +33870,7 @@ pub struct SessionContentExclusionCheckPathsResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionShellExecResult { - /// Unique identifier for tracking streamed output + /// Identifier usable with shell.kill while the process is running pub process_id: String, } @@ -40310,6 +40592,28 @@ pub enum McpPlanInstallResult { Unavailable(CatalogUnavailableError), } +/// The sender role of an MCP prompt message. +/// +///
+/// +/// **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, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpPromptRole { + /// A message from the user. + #[serde(rename = "user")] + User, + /// A message from the assistant. + #[serde(rename = "assistant")] + Assistant, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Outcome of the sampling inference. 'success' produced a response; 'failure' encountered an error (including agent-side rejection by content filter or criteria); 'cancelled' the caller cancelled this execution via cancelSamplingExecution. /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index b266b5db81..cf87a42bd1 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -7704,6 +7704,13 @@ impl<'a> SessionRpcMcp<'a> { } } + /// `session.mcp.prompts.*` sub-namespace. + pub fn prompts(&self) -> SessionRpcMcpPrompts<'a> { + SessionRpcMcpPrompts { + session: self.session, + } + } + /// `session.mcp.resources.*` sub-namespace. pub fn resources(&self) -> SessionRpcMcpResources<'a> { SessionRpcMcpResources { @@ -8754,7 +8761,7 @@ impl<'a> SessionRpcMcpOauth<'a> { /// /// # Parameters /// - /// * `params` - Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + /// * `params` - Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. /// /// # Returns /// @@ -8784,7 +8791,7 @@ impl<'a> SessionRpcMcpOauth<'a> { /// /// # Parameters /// - /// * `params` - Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback success-page copy, and static OAuth client selection. + /// * `params` - Remote MCP server name and optional overrides controlling reauthentication, OAuth client display name, callback handling, and static OAuth client selection. /// /// # Returns /// @@ -8813,6 +8820,32 @@ impl<'a> SessionRpcMcpOauth<'a> { Ok(serde_json::from_value(_value)?) } + /// Completes a runtime-managed MCP OAuth login after the authorization server redirects to a host-managed callback URL. + /// + /// Wire method: `session.mcp.oauth.complete`. + /// + /// # Parameters + /// + /// * `params` - Host-delivered callback for a runtime-managed MCP OAuth login. + /// + ///
+ /// + /// **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 complete(&self, params: McpOauthCompleteRequest) -> Result<(), Error> { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_MCP_OAUTH_COMPLETE, Some(wire_params)) + .await?; + Ok(()) + } + /// Passively probes a configured remote MCP server to classify whether OAuth is required or a cached/override token is accepted. Does not start OAuth, emit pending OAuth requests, or mutate MCP connection state. /// /// Wire method: `session.mcp.oauth.probe`. @@ -8948,6 +8981,74 @@ impl<'a> SessionRpcMcpOauth<'a> { } } +/// `session.mcp.prompts.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcMcpPrompts<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcMcpPrompts<'a> { + /// Enumerate one page of prompts a connected MCP server exposes (proxies MCP `prompts/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + /// + /// Wire method: `session.mcp.prompts.list`. + /// + /// # Parameters + /// + /// * `params` - MCP server whose prompts to enumerate. + /// + /// # Returns + /// + /// One page of prompts advertised by the named MCP server. + /// + ///
+ /// + /// **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 list(&self, params: McpPromptsListRequest) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_MCP_PROMPTS_LIST, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Get a prompt's messages from a connected MCP server (proxies MCP `prompts/get`). Content is preserved as opaque JSON. Does not send messages to the model, execute tools, or fetch referenced resources. + /// + /// Wire method: `session.mcp.prompts.get`. + /// + /// # Parameters + /// + /// * `params` - MCP server, prompt name, and optional string-valued arguments. + /// + /// # Returns + /// + /// Prompt messages returned by the MCP server without sending them to the model. + /// + ///
+ /// + /// **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(&self, params: McpPromptsGetRequest) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_MCP_PROMPTS_GET, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `session.mcp.resources.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcMcpResources<'a> { @@ -12567,17 +12668,17 @@ pub struct SessionRpcShell<'a> { } impl<'a> SessionRpcShell<'a> { - /// Starts a shell command and streams output through session notifications. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via "shell.kill", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via "setsid") leaves the signalled group, so either can leave a background process running. + /// Starts a shell command, returning an RPC error if it cannot be spawned. The command runs as the leader of its own process group (POSIX) or in a dedicated job object (Windows), so a forced termination — via "shell.kill", the request timeout, or session disposal — signals that whole group/job rather than only the direct child. Two gaps are worth planning for: a command that exits on its own does not trigger that teardown, and on POSIX a descendant that moves itself into a new session or process group (for example via "setsid") leaves the signalled group, so either can leave a background process running. /// /// Wire method: `session.shell.exec`. /// /// # Parameters /// - /// * `params` - Shell command to run, with optional working directory and timeout in milliseconds. + /// * `params` - Shell command to run, with optional working directory and timeout in milliseconds. Spawn failures return an RPC error. /// /// # Returns /// - /// Identifier of the spawned process, used to correlate streamed output and exit notifications. + /// Identifier of the spawned shell process, usable with shell.kill while the process is running. /// ///
/// diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 9bca71a31f..00a479147a 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -3749,6 +3749,10 @@ pub struct PromptCacheBreakData { #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] pub(crate) tools_redefined: Option>, + /// Changed definition parts of redefined tools, as `tool:part` entries; property-level parts only for telemetry-safe tools, whose other names are hashed + #[doc(hidden)] + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tools_redefined_parts: Option>, /// Raw names of tools redefined since the prior call, restricted because a tool name can be user-authored #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] @@ -4096,6 +4100,16 @@ pub struct ToolExecutionPartialResultData { pub struct ToolExecutionProgressData { /// Human-readable progress status message (e.g., from an MCP server) pub progress_message: String, + /// Client-only structured progress metadata. Not model-facing tool output. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, /// Tool call ID this progress notification belongs to pub tool_call_id: String, } @@ -7410,6 +7424,9 @@ pub struct SessionAutoModeResolvedData { /// The routing method the server applied, when Auto Intent ran #[serde(skip_serializing_if = "Option::is_none")] pub routing_method: Option, + /// Short human-readable sentence from the routing service explaining why this model was chosen, for display alongside the model. Present only when the service supplied one: it is omitted for on-device selections, when the service did not provide an explanation, and when a replayed decision made no routing call. The text is display-only and drawn from a fixed catalogue; several distinct routing categories share identical wording, so it cannot be used to recover the category or keyed on programmatically. + #[serde(skip_serializing_if = "Option::is_none")] + pub selection_reason: Option, /// Whether a sticky model choice overrode the router result #[serde(skip_serializing_if = "Option::is_none")] pub sticky_override: Option, diff --git a/rust/src/installation_confirmation.rs b/rust/src/installation_confirmation.rs index 9e0ef39f29..42a52092ba 100644 --- a/rust/src/installation_confirmation.rs +++ b/rust/src/installation_confirmation.rs @@ -140,6 +140,7 @@ impl InstallationConfirmationDispatcher { let _ = self.client.set(client); } + #[cfg(any(feature = "runtime", test, feature = "test-support"))] pub(crate) fn set_handler(&self, handler: Option>) { *self.handler.write() = handler; } diff --git a/rust/tests/e2e/commands.rs b/rust/tests/e2e/commands.rs index 4fb4b69b78..68b23198a6 100644 --- a/rust/tests/e2e/commands.rs +++ b/rust/tests/e2e/commands.rs @@ -253,4 +253,4 @@ fn assert_command( assert!(!command.description.trim().is_empty()); } static E2E: super::support::SharedE2eGroup = - super::support::SharedE2eGroup::standard("commands", 4); + super::support::SharedE2eGroup::standard("commands", 3); diff --git a/rust/tests/mcp_prompts_test.rs b/rust/tests/mcp_prompts_test.rs new file mode 100644 index 0000000000..2fd54d89e5 --- /dev/null +++ b/rust/tests/mcp_prompts_test.rs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +#![allow(clippy::unwrap_used)] + +use std::path::Path; + +use github_copilot_sdk::rpc::{ + McpPromptsGetRequest, McpPromptsGetResult, McpPromptsListRequest, McpPromptsListResult, +}; +use serde_json::{Value, json}; + +// Serialization coverage complements Node/.NET's real MCP boundary tests. +#[test] +fn prompt_results_preserve_opaque_content_and_optional_fields() { + let fixture_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/harness/mcp-prompt-fixtures.json"); + let fixtures: Value = + serde_json::from_str(&std::fs::read_to_string(fixture_path).unwrap()).unwrap(); + for page in ["firstPage", "secondPage"] { + let result: McpPromptsListResult = serde_json::from_value(fixtures[page].clone()).unwrap(); + assert_eq!(serde_json::to_value(result).unwrap(), fixtures[page]); + } + let result: McpPromptsGetResult = + serde_json::from_value(fixtures["richPrompt"].clone()).unwrap(); + assert_eq!( + serde_json::to_value(result).unwrap(), + fixtures["richPrompt"] + ); +} + +#[test] +fn prompt_requests_preserve_omitted_empty_and_string_arguments() { + for arguments in [ + None, + Some(json!({})), + Some(json!({"topic": "日本語", "style": ""})), + ] { + let mut wire = json!({"serverName": "fixture", "promptName": "rich"}); + if let Some(arguments) = arguments { + wire["arguments"] = arguments; + } + let request: McpPromptsGetRequest = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), wire); + } + for wire in [ + json!({"serverName": "fixture"}), + json!({"serverName": "fixture", "cursor": "page:2/opaque+cursor="}), + ] { + let request: McpPromptsListRequest = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), wire); + } + assert!( + serde_json::from_value::( + json!({"serverName": "fixture", "promptName": "rich", "arguments": {"topic": 42}}) + ) + .is_err() + ); +} diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 93d785763f..2ee5c24ee4 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -8,7 +8,6 @@ import { execFile } from "child_process"; import fs from "fs/promises"; -import { realpathSync } from "node:fs"; import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; @@ -19,6 +18,7 @@ import { getApiSchemaPath, getRpcSchemaTypeName, getSessionEventsSchemaPath, + isCodegenEntrypoint, writeGeneratedFile, collectExternalSchemaRefNames, collectDefinitionCollections, @@ -3016,7 +3016,7 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(__filename)) { +if (isCodegenEntrypoint(process.argv[1], __filename)) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 0ef18a81d8..eb1da853ca 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -8,7 +8,6 @@ import { execFile } from "child_process"; import fs from "fs/promises"; -import { realpathSync } from "node:fs"; import type { JSONSchema7 } from "json-schema"; import path from "path"; import { fileURLToPath } from "url"; @@ -30,6 +29,7 @@ import { getNullableInner, getRpcSchemaTypeName, getSessionEventsSchemaPath, + isCodegenEntrypoint, getSessionEventVariantSchemas, getSharedSessionEventEnvelopeProperties, hasSchemaPayload, @@ -4635,7 +4635,7 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(__filename)) { +if (isCodegenEntrypoint(process.argv[1], __filename)) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index 81f6328c01..f696fa0b15 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -7,8 +7,6 @@ */ import fs from "fs/promises"; -import { realpathSync } from "node:fs"; -import path from "path"; import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import { fileURLToPath } from "url"; import { @@ -19,6 +17,7 @@ import { getApiSchemaPath, getRpcSchemaTypeName, getSessionEventsSchemaPath, + isCodegenEntrypoint, isObjectSchema, isOpaqueJson, isVoidSchema, @@ -1368,7 +1367,8 @@ function removeRequiredAnyDefaultsForPython( } const requiredFields = resolved.required.map(toSnakeCase); - for (const className of new Set([definitionName, toPascalCase(definitionName)])) { + // Quicktype capitalizes acronyms, for example McpPromptMessage becomes MCPPromptMessage. + for (const className of new Set([definitionName.toLowerCase(), toPascalCase(definitionName).toLowerCase()])) { const fields = requiredFieldsByClass.get(className) ?? new Set(); for (const field of requiredFields) { fields.add(field); @@ -1379,7 +1379,7 @@ function removeRequiredAnyDefaultsForPython( const classBlockRe = /(@dataclass\r?\nclass\s+(\w+):[\s\S]*?)(?=^@dataclass|^class\s+\w|^def\s+\w|\Z)/gm; return code.replace(classBlockRe, (block: string, _classPrefix: string, className: string) => { - const requiredFields = requiredFieldsByClass.get(className); + const requiredFields = requiredFieldsByClass.get(className.toLowerCase()); if (!requiredFields) { return block; } @@ -4385,7 +4385,7 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(__filename)) { +if (isCodegenEntrypoint(process.argv[1], __filename)) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index cd7729ce3e..8f46864b20 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -7,10 +7,8 @@ */ import fs from "fs/promises"; -import { realpathSync } from "fs"; import type { JSONSchema7 } from "json-schema"; import { compile } from "json-schema-to-typescript"; -import path from "path"; import { fileURLToPath } from "url"; import { getApiSchemaPath, @@ -18,6 +16,7 @@ import { getNullableInner, getRpcSchemaTypeName, getSessionEventsSchemaPath, + isCodegenEntrypoint, postProcessSchema, propagateInternalVisibility, writeGeneratedFile, @@ -1021,7 +1020,7 @@ function emitGroup( // sessionId is already stripped from the generated type definition, // so no need for Omit<..., "sessionId"> sigParams.push(`params${optMark}: ${paramsType}`); - bodyArg = "{ sessionId, ...params }"; + bodyArg = "{ ...params, sessionId }"; } else { bodyArg = "{ sessionId }"; } @@ -1336,29 +1335,7 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); -export function isTypeScriptCodegenEntrypoint( - entryPath: string | undefined, - modulePath = __filename, - platform = process.platform, -): boolean { - if (!entryPath) { - return false; - } - const canonicalize = (filePath: string) => { - try { - return realpathSync.native(filePath); - } catch { - return path.resolve(filePath); - } - }; - const canonicalEntryPath = canonicalize(entryPath); - const canonicalModulePath = canonicalize(modulePath); - return platform === "win32" - ? canonicalEntryPath.toLowerCase() === canonicalModulePath.toLowerCase() - : canonicalEntryPath === canonicalModulePath; -} - -if (isTypeScriptCodegenEntrypoint(process.argv[1])) { +if (isCodegenEntrypoint(process.argv[1], __filename)) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 078e45c4b9..5bb3c9f995 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -8,6 +8,7 @@ import { execFile } from "child_process"; import fs from "fs/promises"; +import { realpathSync } from "fs"; import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import path from "path"; import { fileURLToPath } from "url"; @@ -24,6 +25,32 @@ const __dirname = path.dirname(__filename); /** Root of the copilot-sdk repo */ export const REPO_ROOT = path.resolve(__dirname, "../.."); +/** Recognizes entrypoints reached through Bazel links or Windows path casing. */ +export function isCodegenEntrypoint( + entryPath: string | undefined, + modulePath: string, + platform = process.platform, +): boolean { + if (!entryPath) { + return false; + } + const canonicalize = (filePath: string) => { + try { + return realpathSync.native(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + return path.resolve(filePath); + } + }; + const canonicalEntryPath = canonicalize(entryPath); + const canonicalModulePath = canonicalize(modulePath); + return platform === "win32" + ? canonicalEntryPath.toLowerCase() === canonicalModulePath.toLowerCase() + : canonicalEntryPath === canonicalModulePath; +} + /** Event types to exclude from generation (internal/legacy types) */ export const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); diff --git a/test/harness/mcp-prompt-fixtures.json b/test/harness/mcp-prompt-fixtures.json new file mode 100644 index 0000000000..41fe307adc --- /dev/null +++ b/test/harness/mcp-prompt-fixtures.json @@ -0,0 +1,129 @@ +{ + "$comment": "Copyright (c) Microsoft Corporation. All rights reserved.", + "firstPage": { + "prompts": [ + { + "name": "rich", + "title": "Rich prompt", + "description": "Ordered opaque content", + "icons": [ + { + "src": "data:image/png;base64,aW1hZ2U=", + "mimeType": "image/png", + "sizes": ["16x16", "32x32"], + "theme": "dark", + "additionalProperties": { + "x-icon": { "nested": [null, false] }, + "additionalProperties": { "literal": false } + } + } + ], + "_meta": { "descriptor": { "version": 1 } }, + "additionalProperties": { + "x-prompt": { "nested": [null, false] }, + "additionalProperties": { "literal": false } + }, + "arguments": [ + { + "name": "topic", + "description": "Required topic", + "required": true, + "_meta": { "argument": { "label": "Topic" } }, + "additionalProperties": { + "x-argument": { "nested": [null, false] }, + "additionalProperties": { "literal": false } + } + }, + { "name": "style", "description": "Optional style", "required": false }, + { "name": "extra" } + ] + } + ], + "nextCursor": "page:2/opaque+cursor=", + "_meta": { "page": { "number": 1 } }, + "additionalProperties": { + "x-list": { "nested": [null, false] }, + "additionalProperties": { "literal": false } + } + }, + "secondPage": { + "prompts": [ + { "name": "echo", "arguments": [] }, + { "name": "refresh", "description": "Publish a list change" } + ], + "_meta": { "page": { "number": 2 } } + }, + "richPrompt": { + "description": "A prompt is data, not a model invocation", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Explain opaque content", + "annotations": { "audience": ["user"], "priority": 0.75 }, + "_meta": { "nested": { "values": [true, null, 3] } }, + "futureField": { "preserve": false } + }, + "_meta": { "message": { "index": 0 } }, + "additionalProperties": { + "x-message": { "nested": [null, false] }, + "additionalProperties": { "literal": false } + } + }, + { + "role": "assistant", + "content": { "type": "image", "data": "aW1hZ2U=", "mimeType": "image/png" } + }, + { + "role": "user", + "content": { "type": "audio", "data": "YXVkaW8=", "mimeType": "audio/wav" } + }, + { + "role": "assistant", + "content": { + "type": "resource", + "resource": { + "uri": "test://prompt/text", + "mimeType": "text/plain", + "text": "Embedded text", + "_meta": { "origin": { "embedded": true } } + } + } + }, + { + "role": "user", + "content": { + "type": "resource", + "resource": { "uri": "test://prompt/blob", "mimeType": "application/octet-stream", "blob": "AAE=" } + } + }, + { + "role": "assistant", + "content": { + "type": "resource_link", + "uri": "test://prompt/not-fetched", + "name": "Reference", + "title": "Resource link", + "description": "Must not be fetched", + "mimeType": "text/plain", + "size": 42, + "annotations": { "audience": ["assistant"] } + } + }, + { + "role": "user", + "content": { + "type": "future-content", + "payload": { "items": [1, false, null, { "key": "value" }] }, + "_meta": { "extension": ["kept"] } + } + } + ], + "_meta": { "result": { "source": "fixture", "flags": [false, true] } }, + "additionalProperties": { + "x-get": { "nested": [null, false] }, + "additionalProperties": { "literal": false } + } + } +} diff --git a/test/harness/test-mcp-oauth-server.mjs b/test/harness/test-mcp-oauth-server.mjs index d5d28d8438..5d82bb5c4a 100644 --- a/test/harness/test-mcp-oauth-server.mjs +++ b/test/harness/test-mcp-oauth-server.mjs @@ -127,6 +127,30 @@ export async function startOAuthMcpServer({ return; } + if (req.method === "POST" && url.pathname === "/token") { + requests.push({ + method: req.method, + path: url.pathname, + authorization: req.headers.authorization ?? null, + body, + }); + const form = new URLSearchParams(body); + if ( + form.get("grant_type") !== "authorization_code" || + form.get("code") !== "accepted-code" || + !form.get("code_verifier") + ) { + respondJson(res, 400, { error: "invalid_grant" }); + return; + } + respondJson(res, 200, { + access_token: expectedToken, + token_type: "Bearer", + expires_in: 3600, + }); + return; + } + if (url.pathname !== "/mcp") { respondJson(res, 404, { error: "not_found" }); return; diff --git a/test/harness/test-mcp-server.mjs b/test/harness/test-mcp-server.mjs index ab17776900..94fcd63888 100644 --- a/test/harness/test-mcp-server.mjs +++ b/test/harness/test-mcp-server.mjs @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /** - * Minimal MCP server that exposes a `get_env` tool. + * Minimal MCP server that exposes a `get_env` tool and deterministic prompts. * Returns the value of a named environment variable from this process. * Used by SDK E2E tests to verify that literal env values reach MCP server subprocesses. * @@ -13,7 +13,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { appendFile } from "node:fs/promises"; +import { + GetPromptRequestSchema, + ListPromptsRequestSchema, + McpError, + ErrorCode, +} from "@modelcontextprotocol/sdk/types.js"; +import { appendFile, readFile } from "node:fs/promises"; import { z } from "zod"; function getArgument(name) { @@ -25,6 +31,67 @@ const startupMarkerPath = getArgument("--startup-marker"); const diagnosticStderr = getArgument("--diagnostic-stderr"); const serverName = getArgument("--server-name") ?? "env-echo"; const server = new McpServer({ name: serverName, version: "1.0.0" }); +const fixtures = JSON.parse(await readFile(new URL("./mcp-prompt-fixtures.json", import.meta.url), "utf8")); +// Fixtures contain SDK extension bags; MCP sends those entries as ordinary object fields. +function toMcpObject(value) { + const { additionalProperties, ...fields } = value; + return { ...fields, ...additionalProperties }; +} + +function toMcpPromptPage(page) { + return { + ...toMcpObject(page), + prompts: page.prompts.map((prompt) => ({ + ...toMcpObject(prompt), + ...(prompt.arguments ? { arguments: prompt.arguments.map(toMcpObject) } : {}), + ...(prompt.icons ? { icons: prompt.icons.map(toMcpObject) } : {}), + })), + }; +} + +const prompts = { + firstPage: toMcpPromptPage(fixtures.firstPage), + secondPage: toMcpPromptPage(fixtures.secondPage), + richPrompt: { + ...toMcpObject(fixtures.richPrompt), + messages: fixtures.richPrompt.messages.map(toMcpObject), + }, +}; +let promptsChanged = false; + +server.server.registerCapabilities({ prompts: { listChanged: true } }); +server.server.setRequestHandler(ListPromptsRequestSchema, async ({ params }) => { + if (params?.cursor === undefined) { + return prompts.firstPage; + } + if (params.cursor !== prompts.firstPage.nextCursor) { + throw new McpError(ErrorCode.InvalidParams, "Unknown prompt cursor"); + } + return { + ...prompts.secondPage, + prompts: [...prompts.secondPage.prompts, ...(promptsChanged ? [{ name: "added" }] : [])], + }; +}); +// Use the protocol handler so unknown content and extension fields reach clients unchanged. +server.server.setRequestHandler(GetPromptRequestSchema, async ({ params }) => { + if (params.name === "rich") { + if (params.arguments?.topic === undefined) { + throw new McpError(ErrorCode.InvalidParams, "Missing required argument: topic"); + } + return prompts.richPrompt; + } + if (params.name === "echo") { + return { + messages: [{ role: "user", content: { type: "text", text: JSON.stringify(params.arguments ?? null) } }], + }; + } + if (params.name === "refresh") { + promptsChanged = true; + await server.server.notification({ method: "notifications/prompts/list_changed" }); + return { messages: [] }; + } + throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${params.name}`); +}); server.tool( "get_env", diff --git a/test/harness/test-mcp-session-expiry-server.mjs b/test/harness/test-mcp-session-expiry-server.mjs new file mode 100644 index 0000000000..98b49039e5 --- /dev/null +++ b/test/harness/test-mcp-session-expiry-server.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Minimal Streamable HTTP MCP server for SDK E2E tests that need to observe + * remote session closure and reconnection. + * + * The `/mcp` endpoint issues a real `mcp-session-id` per connection and serves + * enough JSON-RPC MCP methods for the runtime to initialize, list tools and call + * one tool. `POST /__expire` invalidates every active session so subsequent + * requests (including the runtime's liveness `ping`) answer `404`, which is how a + * server-side idle expiry is observed by a real Streamable HTTP client. + * `GET /__stats` reports the counters tests assert on. + */ + +import http from "node:http"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +const PROTOCOL_VERSION = "2025-03-26"; + +export async function startSessionExpiryMcpServer({ host = "127.0.0.1", port = 0 } = {}) { + const activeSessions = new Set(); + const expiredSessions = new Set(); + const stats = { initializations: 0, toolsListRequests: 0, toolCalls: 0 }; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `${host}:${port}`}`); + + if (req.method === "GET" && url.pathname === "/__stats") { + respondJson(res, 200, { ...stats, activeSessions: activeSessions.size }); + return; + } + + if (req.method === "POST" && url.pathname === "/__expire") { + for (const sessionId of activeSessions) { + expiredSessions.add(sessionId); + } + activeSessions.clear(); + respondJson(res, 200, { expired: expiredSessions.size }); + return; + } + + if (url.pathname !== "/mcp") { + respondJson(res, 404, { error: "not_found" }); + return; + } + + const sessionId = req.headers["mcp-session-id"]; + if (typeof sessionId === "string" && expiredSessions.has(sessionId)) { + respondJson(res, 404, { error: "session_expired" }); + return; + } + + if (req.method !== "POST") { + respondJson(res, 405, { error: "method_not_allowed" }); + return; + } + + const parsedBody = parseJsonBody(await readBody(req)); + if (!parsedBody.ok) { + respondJson(res, 400, { error: "invalid_json" }); + return; + } + + const messages = Array.isArray(parsedBody.value) ? parsedBody.value : [parsedBody.value]; + const isInitialize = messages.some((message) => message?.method === "initialize"); + let responseSessionId = typeof sessionId === "string" ? sessionId : undefined; + + if (isInitialize) { + responseSessionId = randomUUID(); + activeSessions.add(responseSessionId); + stats.initializations++; + } else if (responseSessionId === undefined || !activeSessions.has(responseSessionId)) { + respondJson(res, 404, { error: "session_not_found" }); + return; + } + + const responses = messages + .map((message) => handleJsonRpcMessage(message, stats)) + .filter((message) => message !== undefined); + + if (responses.length === 0) { + res.writeHead(202, { "mcp-session-id": responseSessionId }); + res.end(); + return; + } + + const payload = JSON.stringify(Array.isArray(parsedBody.value) ? responses : responses[0]); + res.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + "mcp-session-id": responseSessionId, + }); + res.end(payload); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP server address"); + } + + return { + url: `http://${host}:${address.port}`, + stats, + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + server.closeAllConnections(); + }), + }; +} + +function handleJsonRpcMessage(message, stats) { + if (!message || typeof message !== "object" || !("id" in message)) { + return undefined; + } + + switch (message.method) { + case "initialize": + return { + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "session-expiry-test-server", version: "1.0.0" }, + }, + }; + case "ping": + return { jsonrpc: "2.0", id: message.id, result: {} }; + case "tools/list": + stats.toolsListRequests++; + return { + jsonrpc: "2.0", + id: message.id, + result: { + tools: [ + { + name: "remote_ping", + description: "Returns pong to prove the remote MCP connection is functional.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + _meta: { "ui.visibility": ["model", "app"] }, + }, + ], + }, + }; + case "tools/call": + stats.toolCalls++; + return { + jsonrpc: "2.0", + id: message.id, + result: { content: [{ type: "text", text: "remote pong" }], isError: false }, + }; + default: + return { + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: `Method not found: ${message.method}` }, + }; + } +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("error", reject); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +function parseJsonBody(body) { + if (!body) { + return { ok: true, value: undefined }; + } + + try { + return { ok: true, value: JSON.parse(body) }; + } catch { + return { ok: false, value: undefined }; + } +} + +function respondJson(res, statusCode, body) { + const data = JSON.stringify(body); + res.writeHead(statusCode, { + "content-type": "application/json", + "content-length": Buffer.byteLength(data), + }); + res.end(data); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const server = await startSessionExpiryMcpServer(); + console.log(`Listening: ${server.url}`); + process.on("SIGTERM", async () => { + await server.close(); + process.exit(0); + }); +} diff --git a/test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_cold_resume.yaml b/test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_cold_resume.yaml new file mode 100644 index 0000000000..7919866bd9 --- /dev/null +++ b/test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_cold_resume.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Reply with exactly COAUTHOR_DISABLED_OK. + - role: assistant + content: COAUTHOR_DISABLED_OK + - role: user + content: Reply with exactly COAUTHOR_ENABLED_OK. + - role: assistant + content: COAUTHOR_ENABLED_OK diff --git a/test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_options_update.yaml b/test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_options_update.yaml new file mode 100644 index 0000000000..7919866bd9 --- /dev/null +++ b/test/snapshots/commit_trailer_resume/allows_coauthor_opt_in_after_an_initial_opt_out_via_options_update.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Reply with exactly COAUTHOR_DISABLED_OK. + - role: assistant + content: COAUTHOR_DISABLED_OK + - role: user + content: Reply with exactly COAUTHOR_ENABLED_OK. + - role: assistant + content: COAUTHOR_ENABLED_OK diff --git a/test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__false_.yaml b/test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__false_.yaml new file mode 100644 index 0000000000..33e8b3aee2 --- /dev/null +++ b/test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__false_.yaml @@ -0,0 +1,18 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Reply with exactly TRAILER_INITIAL_OK. + - role: assistant + content: TRAILER_INITIAL_OK + - role: user + content: Reply with exactly TRAILER_RESUMED_OK. + - role: assistant + content: TRAILER_RESUMED_OK + - role: user + content: Reply with exactly TRAILER_OPT_OUT_OK. + - role: assistant + content: TRAILER_OPT_OUT_OK diff --git a/test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__true_.yaml b/test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__true_.yaml new file mode 100644 index 0000000000..33e8b3aee2 --- /dev/null +++ b/test/snapshots/commit_trailer_resume/restores_uncustomized_attribution_across_cold_resume__quoted_tags__true_.yaml @@ -0,0 +1,18 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Reply with exactly TRAILER_INITIAL_OK. + - role: assistant + content: TRAILER_INITIAL_OK + - role: user + content: Reply with exactly TRAILER_RESUMED_OK. + - role: assistant + content: TRAILER_RESUMED_OK + - role: user + content: Reply with exactly TRAILER_OPT_OUT_OK. + - role: assistant + content: TRAILER_OPT_OUT_OK