From e69751d1c80871aaa1c0e45bd360aeeaa352828f Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 10 Sep 2026 13:02:34 +0200 Subject: [PATCH 1/3] canvas: feat: add local-canvases protocol contract (LC-17) Introduces a versioned, renderer-neutral `ahp-canvas:` channel for local extension/package-rendered canvas surfaces, promoting the VS Code PoC transport into the canonical AHP contract. - Stable identity: CanvasIdentityKey (chat, source, canvasType, instanceId) plus a host-assigned, opaque `incarnation` token that is never reused for a superseded logical instance; CanvasPackageSource carries a dedicated `sourceId` distinct from display-only `packageName`/`version`. - Lifecycle state: CanvasTrustState (trusted/pending/blocked) and CanvasAvailabilityState (unsupported/notLoaded/loading/empty/ready/failed) are independent axes; SessionState.canvases is durable membership, distinct from the new read-only listCanvasTypes discovery catalogue. - Commands: listCanvasTypes (read-only type discovery), openCanvas (requestId-scoped idempotency; a new requestId for an already-open identity may re-invoke the provider without creating a second identity), resolveCanvasSource (read-only current-state/live-URL read, never opens/resumes/restarts), invokeCanvasAction (required incarnation precondition, no exactly-once-across-crash promise), restartCanvasProvider (the only operation that bumps incarnation), and closeCanvas (required revision precondition; logical close, distinct from hiding a tab). - Capability: new server/runtime-side CanvasCapabilities / InitializeResult.canvases, independent of the existing client-side ClientCapabilities.canvases and of protocol-version negotiation alone. - Bounds: declared, enforceable size/depth limits (request id length, identity field length, schema property/depth, declared-action count, input/result length) plus an isCanvasSchemaWithinLimits helper. - canvasReducer and sessionReducer's session/canvasSet consistently reject stale-revision actions rather than special-casing incarnation. Bumps PROTOCOL_VERSION to 0.10.0 and regenerates all client mirrors (Rust, Kotlin, Swift, Go, .NET, TypeScript), JSON schemas, and docs via `npm run generate`. Adds reducer fixtures and boundary tests for the new channel; CHANGELOG.md heading and fragment collapse remain a release-time step per RELEASING.md, not part of this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/dotnet/release-metadata.json | 1 + .../Generated/Actions.generated.cs | 118 ++ .../Generated/Commands.generated.cs | 355 ++++ .../JsonSerializerContext.generated.cs | 41 + .../Generated/State.generated.cs | 512 ++++++ .../Generated/Version.generated.cs | 3 +- .../Generated/ActionMetadata.generated.cs | 24 + clients/go/ahptypes/actions.generated.go | 132 ++ clients/go/ahptypes/commands.generated.go | 295 ++++ clients/go/ahptypes/state.generated.go | 607 +++++++ clients/go/ahptypes/version.generated.go | 3 +- clients/go/release-metadata.json | 1 + clients/kotlin/release-metadata.json | 1 + .../generated/Actions.generated.kt | 98 ++ .../generated/Commands.generated.kt | 264 ++- .../generated/State.generated.kt | 659 +++++++ .../generated/Version.generated.kt | 3 +- clients/rust/crates/ahp-types/src/actions.rs | 122 ++ clients/rust/crates/ahp-types/src/commands.rs | 331 ++++ clients/rust/crates/ahp-types/src/state.rs | 534 ++++++ clients/rust/crates/ahp-types/src/version.rs | 7 +- clients/rust/release-metadata.json | 1 + .../Generated/Actions.generated.swift | 146 ++ .../Generated/Commands.generated.swift | 340 +++- .../Generated/State.generated.swift | 716 ++++++++ .../Generated/Version.generated.swift | 3 +- clients/swift/release-metadata.json | 1 + clients/typescript/release-metadata.json | 1 + .../20260909-local-canvas-protocol.json | 5 + schema/actions.schema.json | 666 +++++++ schema/commands.schema.json | 1021 ++++++++++- schema/errors.schema.json | 1529 +++++++++++++---- schema/notifications.schema.json | 526 ++++++ schema/state.schema.json | 526 ++++++ scripts/find-protocol-sources.ts | 1 + scripts/generate-action-origin.ts | 34 +- scripts/generate-csharp.ts | 68 + scripts/generate-go.ts | 80 +- scripts/generate-kotlin.ts | 66 +- scripts/generate-rust.ts | 77 +- scripts/generate-swift.ts | 66 +- types/action-origin.generated.ts | 37 + types/actions.ts | 1 + types/channels-canvas.test.ts | 63 + types/channels-canvas/actions.ts | 93 + types/channels-canvas/commands.ts | 332 ++++ types/channels-canvas/reducer.ts | 51 + types/channels-canvas/state.ts | 598 +++++++ types/channels-session/actions.ts | 39 + types/channels-session/reducer.ts | 28 + types/channels-session/state.ts | 10 + types/commands.ts | 1 + types/common/actions.ts | 22 +- types/common/commands.ts | 46 + types/common/messages.ts | 18 + types/common/reducer-helpers.ts | 4 +- types/common/state.ts | 3 +- types/index.ts | 1 + types/messages.test.ts | 1 + types/reducers.test.ts | 26 +- types/reducers.ts | 1 + types/state.ts | 1 + ...nged-updates-state-and-bumps-revision.json | 40 + ...rustchanged-blocks-and-bumps-revision.json | 36 + ...hanged-bumps-incarnation-and-revision.json | 36 + ...nged-updates-title-and-bumps-revision.json | 36 + ...4-canvas-unknown-action-type-is-no-op.json | 36 + ...-session-canvasset-appends-new-canvas.json | 55 + ...on-canvasset-replaces-existing-canvas.json | 71 + ...ved-removes-matching-and-noop-unknown.json | 41 + ...vasremoved-noop-without-canvases-list.json | 23 + ...ing-a-would-be-incarnation-regression.json | 36 + ...sion-canvasset-rejects-stale-revision.json | 71 + types/version/message-checks.ts | 8 +- types/version/registry.ts | 9 +- 75 files changed, 11492 insertions(+), 366 deletions(-) create mode 100644 docs/.changes/20260909-local-canvas-protocol.json create mode 100644 types/channels-canvas.test.ts create mode 100644 types/channels-canvas/actions.ts create mode 100644 types/channels-canvas/commands.ts create mode 100644 types/channels-canvas/reducer.ts create mode 100644 types/channels-canvas/state.ts create mode 100644 types/test-cases/reducers/280-canvas-availabilitychanged-updates-state-and-bumps-revision.json create mode 100644 types/test-cases/reducers/281-canvas-trustchanged-blocks-and-bumps-revision.json create mode 100644 types/test-cases/reducers/282-canvas-incarnationchanged-bumps-incarnation-and-revision.json create mode 100644 types/test-cases/reducers/283-canvas-titlechanged-updates-title-and-bumps-revision.json create mode 100644 types/test-cases/reducers/284-canvas-unknown-action-type-is-no-op.json create mode 100644 types/test-cases/reducers/285-session-canvasset-appends-new-canvas.json create mode 100644 types/test-cases/reducers/286-session-canvasset-replaces-existing-canvas.json create mode 100644 types/test-cases/reducers/287-session-canvasremoved-removes-matching-and-noop-unknown.json create mode 100644 types/test-cases/reducers/288-session-canvasremoved-noop-without-canvases-list.json create mode 100644 types/test-cases/reducers/289-canvas-rejects-stale-revision-including-a-would-be-incarnation-regression.json create mode 100644 types/test-cases/reducers/290-session-canvasset-rejects-stale-revision.json diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json index 128ac239..c745db9d 100644 --- a/clients/dotnet/release-metadata.json +++ b/clients/dotnet/release-metadata.json @@ -2,6 +2,7 @@ "client": "dotnet", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 01ee2af4..2d100f35 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -205,6 +205,18 @@ public enum ActionType AutomationRunPrimarySessionChanged, [WireValue("automationRun/cancelRequested")] AutomationRunCancelRequested, + [WireValue("session/canvasSet")] + SessionCanvasSet, + [WireValue("session/canvasRemoved")] + SessionCanvasRemoved, + [WireValue("canvas/availabilityChanged")] + CanvasAvailabilityChanged, + [WireValue("canvas/trustChanged")] + CanvasTrustChanged, + [WireValue("canvas/incarnationChanged")] + CanvasIncarnationChanged, + [WireValue("canvas/titleChanged")] + CanvasTitleChanged, } // ─── Action Envelope ───────────────────────────────────────────────── @@ -2487,6 +2499,106 @@ public sealed record AutomationRunCancelRequestedAction public ActionType Type { get; init; } } +/// A canvas was admitted (opened) or its catalog entry changed. +/// +/// Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the +/// server dispatches this with the full entry to record a newly opened +/// canvas, or to republish it after a trust/availability/incarnation change +/// so subscribers following only the session channel stay in sync with +/// {@link CanvasState}. Never client-dispatchable — canvases are admitted +/// only through the `openCanvas` command. A stale/out-of-order delivery +/// (`canvas.revision` not strictly greater than the currently-recorded +/// entry's revision) MUST be rejected (no-op) rather than overwrite a newer +/// entry with older data. +public sealed record SessionCanvasSetAction +{ + public ActionType Type { get; init; } + + /// The canvas entry to add or update, matched by `resource`. + public required CanvasEntry Canvas { get; init; } +} + +/// A canvas was logically closed. +/// +/// Remove semantics keyed by `resource`: an unknown URI is a no-op. This +/// represents durable membership removal, not a client hiding a local +/// tab/view — see `closeCanvas`. +public sealed record SessionCanvasRemovedAction +{ + public ActionType Type { get; init; } + + /// Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + public required string Resource { get; init; } +} + +/// Replaces the canvas's live resolution state. +/// +/// Dispatched by the host on every availability transition: initial +/// resolution after `openCanvas`, provider restart, reload, and failure. +public sealed record CanvasAvailabilityChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasState.availability}. + public required CanvasAvailabilityState Availability { get; init; } + + /// The {@link CanvasState.revision} this action results in. The reducer + /// MUST reject (no-op) this action if `revision` is not strictly greater + /// than the canvas's current `revision` — this is how stale/out-of-order + /// deliveries are consistently rejected across every canvas action, not + /// just this one. + public long Revision { get; init; } +} + +/// Replaces the canvas's trust decision. +/// +/// Dispatched by the host whenever the execution-trust decision for this +/// canvas's declared actions changes (e.g. a pending decision resolves, or an +/// administrator revokes a previously trusted source). +public sealed record CanvasTrustChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasState.trust}. + public required CanvasTrustState Trust { get; init; } + + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public long Revision { get; init; } +} + +/// Records that the canvas's live endpoint was replaced by a fresh one for +/// the same logical instance (e.g. the owning provider restarted). +/// +/// The host MUST dispatch {@link CanvasAvailabilityChangedAction} to +/// transition through `notLoaded`/`loading` around this change. Receivers +/// MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed +/// callbacks addressed to a superseded `incarnation` — because `incarnation` +/// is opaque (see {@link CanvasIdentity.incarnation}), that rejection is +/// driven by the accompanying `revision` bump here, not by comparing +/// `incarnation` values for order. +public sealed record CanvasIncarnationChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + public required string Incarnation { get; init; } + + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public long Revision { get; init; } +} + +/// Replaces the canvas's display title. +public sealed record CanvasTitleChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasState.title}. + public required string Title { get; init; } + + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public long Revision { get; init; } +} + // ─── Partial Summaries (action-discovered) ─────────────────────────── /// Partial equivalent of ChatSummary — every field is optional for delta updates. @@ -2666,6 +2778,12 @@ public StateActionConverter() ["automationRun/sessionRemoved"] = typeof(AutomationRunSessionRemovedAction), ["automationRun/primarySessionChanged"] = typeof(AutomationRunPrimarySessionChangedAction), ["automationRun/cancelRequested"] = typeof(AutomationRunCancelRequestedAction), + ["session/canvasSet"] = typeof(SessionCanvasSetAction), + ["session/canvasRemoved"] = typeof(SessionCanvasRemovedAction), + ["canvas/availabilityChanged"] = typeof(CanvasAvailabilityChangedAction), + ["canvas/trustChanged"] = typeof(CanvasTrustChangedAction), + ["canvas/incarnationChanged"] = typeof(CanvasIncarnationChangedAction), + ["canvas/titleChanged"] = typeof(CanvasTitleChangedAction), }, allowUnknown: true) { diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 0ef02d8b..009523d0 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -218,6 +218,24 @@ public sealed record InitializeResult /// host does not expose an automation catalogue or automation commands. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AutomationCapabilities? Automations { get; init; } + + /// Host/runtime-owned local-canvas support. Presence means the SERVER + /// currently has a working runtime able to serve `openCanvas` / + /// `invokeCanvasAction` for at least one qualifying (explicitly installed + /// and trust-eligible) extension/package source; absence means the host + /// has no available canvas runtime, and clients MUST treat every canvas as + /// {@link CanvasAvailabilityStatus.Unsupported} regardless of what + /// {@link ClientCapabilities.canvases} declared. + /// + /// **Protocol version support alone is not a runtime capability**: a host + /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// assumed to have a usable canvas runtime. This field — not the + /// negotiated `protocolVersion` — is the authoritative signal, and is + /// independent of any individual canvas's live availability + /// ({@link CanvasAvailabilityState}) or trust decision + /// ({@link CanvasTrustState}). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CanvasCapabilities? Canvases { get; init; } } /// Identifies a protocol implementation — the software (and build) on one end @@ -269,6 +287,25 @@ public sealed record ClientCapabilities /// App-bearing tool calls as ordinary MCP tool calls. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? McpApps { get; init; } + + /// Client can render local canvases: `listCanvasTypes`, `openCanvas`, + /// subscribe to the resulting `ahp-canvas:` channel, and drive + /// `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + /// `closeCanvas`. + /// + /// Hosts SHOULD NOT offer canvas admission to a client that omits this + /// capability; such a client MUST be treated as if every canvas were + /// {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + /// anything about server/runtime execution trust — see + /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. + /// + /// This declares only the CLIENT's rendering capability. Protocol version + /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// actually has a working canvas runtime — see + /// {@link InitializeResult.canvases}, the server-side counterpart, which a + /// client MUST also check before treating canvases as usable. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Canvases { get; init; } } /// Automation features supported by this host authority. @@ -302,6 +339,13 @@ public sealed record AutomationCapabilities public long? RunHistoryLimit { get; init; } } +/// Local-canvas runtime features supported by this host authority. The empty +/// object means "supported" — see {@link InitializeResult.canvases} for what +/// presence/absence of this field itself means. +public sealed record CanvasCapabilities +{ +} + /// Presence capability for {@link AutomationCreateRequestedAction | /// `automation/createRequested`}. /// @@ -1673,6 +1717,317 @@ public sealed record FetchAutomationRunsResult { } +/// Discovers canvas TYPES currently available to open for one exact backing +/// chat. +/// +/// This is a **pure read/browse** operation: it MUST NOT open, materialize, +/// or otherwise admit any canvas — see `openCanvas` for that. It is +/// unrelated to {@link SessionState.canvases}, which reflects durable +/// membership of already-opened canvas INSTANCES, not the set of canvas +/// TYPES a host/extension could open; do not confuse the two. +public sealed record ListCanvasTypesParams +{ + /// The exact backing chat to discover available canvas types for. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Maximum number of entries to return in this page. The server SHOULD respect + /// this bound but MAY return fewer entries and MAY impose its own upper cap. + /// Omit to let the server choose the page size. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Limit { get; init; } + + /// Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + /// Omit to fetch the first page. Cursors are server-defined and MUST be treated + /// as opaque — do not parse, modify, or persist them across connections. An + /// unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cursor { get; init; } +} + +/// Available canvas types for the requested chat. +public sealed record ListCanvasTypesResult +{ + /// Opaque cursor for the next page. Present when more entries exist beyond the + /// returned page; absent signals the end of the collection. Pass it back as + /// {@link PaginatedParams.cursor} to fetch the following page. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? NextCursor { get; init; } + + /// Discovered canvas type declarations. + public required List Types { get; init; } +} + +/// Explicitly opens (admits) a canvas, associating it with the owning chat +/// given by `identity.chat` at the moment of the call — never with whichever +/// chat later happens to have focus. +/// +/// This is a read-write admission, not a resolve: unlike `subscribe` (which +/// only reads current state), `openCanvas` is the operation that creates +/// durable membership. There is no implicit open — a client MUST call this +/// before a canvas appears in {@link SessionState.canvases}. Once admitted, +/// clients read and follow live state by `subscribe`-ing to the returned +/// `canvas.resource`, and resolve the current live endpoint via +/// `resolveCanvasSource`; neither read itself opens, resumes, or restarts +/// anything. +/// +/// **Logical identity is always singular.** The same {@link CanvasIdentityKey} +/// (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same +/// `canvas` resource URI and the same {@link SessionState.canvases} catalog +/// entry, no matter how many times `openCanvas` is called for it — the server +/// MUST return that existing entry's `resource` rather than mint a second +/// one. A client-supplied `canvas` URI is honored only on the call that first +/// establishes the identity; on a later call for an already-recorded +/// identity the server MUST ignore the supplied `canvas` value and return the +/// existing resource instead. +/// +/// **Idempotency is scoped to `requestId`, not identity.** Retrying with the +/// exact same `requestId` and byte-for-byte identical params from the same +/// authenticated connection MUST return the original result without +/// repeating any side effect, within a bounded live window (the server is +/// not required to remember it forever). Reusing the same `requestId` with +/// any different parameter value MUST be rejected with `Conflict` +/// (`-32011`) — mint a new `requestId` for a new logical call. A genuinely +/// NEW `requestId` for an already-open identity MAY be effectful (e.g. +/// updating `title`/`icon`, or causing the provider to re-run its own +/// open-time initialization with new `input`) — this mirrors the pinned +/// SDK's own repeated-open behavior and does not create a second logical +/// identity. There is no exactly-once-across-crash guarantee: a lost reply +/// is indeterminate, and clients MUST NOT automatically replay `openCanvas` +/// — reconnect and read `SessionState.canvases` / `resolveCanvasSource` +/// instead to determine the actual outcome. +public sealed record OpenCanvasParams +{ + /// Session URI that will list the opened canvas in `SessionState.canvases`. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Canvas URI (client-chosen, e.g. `ahp-canvas:/<uuid>`); honored only when this call first establishes `identity` — see above. + public required string Canvas { get; init; } + + /// Logical identity to open or re-admit. + public required CanvasIdentityKey Identity { get; init; } + + /// Initial (or updated, on a later effectful call) display title. + public required string Title { get; init; } + + /// Initial (or updated) display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; init; } + + /// Bounded JSON input for this open call (e.g. seed parameters the + /// provider uses to initialize the canvas), opaque to the protocol. See + /// {@link CanvasTypeDeclaration.openInputSchema} / + /// `openInputSchemaRef` for the expected shape. The JSON-serialized value + /// MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Input { get; init; } + + /// Durable client-generated idempotency key bounding retry deduplication + /// for this call within a live window; see the idempotency rules above. + /// MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } +} + +/// Result identifying the existing or newly opened canvas. +public sealed record OpenCanvasResult +{ + /// The catalog entry for the opened (or already-open) canvas. + public required CanvasEntry Canvas { get; init; } +} + +/// Pure, read-only read of a canvas's current live-resolution state and, +/// when currently live, a transient endpoint presentation. +/// +/// This MUST NOT create, resume, reopen, or restart a provider. If the +/// canvas does not currently have a live endpoint, `source` is absent and +/// `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`) — +/// call `restartCanvasProvider` (an explicitly effectful operation) to +/// attempt recovery instead. A client-local page reload (re-navigating the +/// client's own rendering surface to the same still-live `source.url`) +/// needs no dedicated command at all; calling `resolveCanvasSource` again is +/// also how a client retries resolving a currently-unavailable source +/// without restarting anything. +public sealed record ResolveCanvasSourceParams +{ + /// The canvas URI (an already-opened canvas's `resource`). + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// The canvas's current live-resolution state as of this read. +public sealed record ResolveCanvasSourceResult +{ + /// Current {@link CanvasEntry.availability}. + public CanvasAvailabilityStatus Availability { get; init; } + + /// Current {@link CanvasIdentity.incarnation}. + public required string Incarnation { get; init; } + + /// Current {@link CanvasEntry.revision}. + public long Revision { get; init; } + + /// Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CanvasSourcePresentation? Source { get; init; } +} + +/// Invokes one of a canvas's currently declared actions exactly once. +/// +/// The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's +/// current trust is not `trusted`, and with `NotFound` (`-32008`) if +/// `actionId` does not match a currently declared action. `incarnation` is +/// REQUIRED — omitting stale-generation protection on an effectful call is +/// not allowed. If it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` +/// (`-32011`) rather than route the call to a superseded endpoint. +/// +/// The result is the provider's raw reply and is never persisted into +/// `CanvasState` — large or provider-specific payloads stay off the durable +/// state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be +/// represented out of band instead of being returned inline. Any resulting +/// state changes (e.g. a subsequent availability transition) flow back +/// separately through the normal `canvas/*` action stream on the canvas's +/// own channel. +/// +/// A lost reply (e.g. a dropped connection after the provider already ran +/// the handler) is **indeterminate**: clients MUST NOT automatically replay +/// `invokeCanvasAction` on reconnect. Instead, reconnect and read the +/// canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and +/// decide from observed `revision`/`incarnation` and any provider-visible +/// side effect whether to surface the ambiguity to the user, rather than +/// assuming success or failure. +public sealed record InvokeCanvasActionParams +{ + /// The canvas URI. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + public required string ActionId { get; init; } + + /// Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + /// if any. The JSON-serialized value MUST NOT exceed + /// `CANVAS_INPUT_MAX_LENGTH`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Input { get; init; } + + /// Expected {@link CanvasIdentity.incarnation}. Required — see above. The + /// server MUST reject the call with `Conflict` if the canvas's live + /// endpoint has since been superseded, rather than deliver the call to it. + public required string Incarnation { get; init; } + + /// Durable client-generated idempotency key bounding retry + /// deduplication for this invocation within a live window. The server is + /// not required to guarantee exactly-once execution across a crash. MUST + /// NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } +} + +/// Result of invoking a declared canvas action. +public sealed record InvokeCanvasActionResult +{ + /// The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + public JsonElement Result { get; init; } +} + +/// Explicitly restarts the provider/chat-scoped runtime backing this canvas: +/// retires the current live endpoint and establishes a fresh one for the +/// same logical instance. +/// +/// This is the **only** operation that intentionally causes an +/// {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` +/// (read-only source resolution / client-local page reload) MUST NEVER +/// trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} +/// (transitioning through `notLoaded`/`loading`) and then +/// {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart +/// never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or +/// restore any prior in-flight effect. +/// +/// `incarnation` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, so a caller cannot restart a +/// generation it never observed (e.g. after racing a concurrent restart). A +/// lost reply is indeterminate; clients MUST NOT automatically replay this +/// command — reconnect and compare the canvas's current `incarnation` +/// instead. +public sealed record RestartCanvasProviderParams +{ + /// The canvas URI. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } + + /// Expected current {@link CanvasIdentity.incarnation}; required — see above. + public required string Incarnation { get; init; } +} + +/// Logically closes a canvas: removes its durable membership from +/// `SessionState.canvases` and disposes matching views. +/// +/// This is distinct from a client merely hiding a local tab or view, which is +/// presentation-only and MUST NOT dispatch this command. There is no +/// advertised model tool for this operation — it is invoked only by +/// UI/RPC callers. +/// +/// `revision` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasEntry.revision}, so a caller cannot close membership state it +/// never actually observed. If no matching entry exists (e.g. already +/// closed), the server MUST treat this as a successful no-op rather than an +/// error — the `revision` precondition only applies when an entry still +/// exists. A lost reply is indeterminate; clients MUST NOT automatically +/// replay this command — reconnect and check `SessionState.canvases` +/// instead. +public sealed record CloseCanvasParams +{ + /// The canvas URI. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } + + /// Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + public long Revision { get; init; } +} + // ─── ReconnectResult Union ──────────────────────────────────────────── /// ReconnectResult is the result of the `reconnect` command. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 9b164021..900d7aeb 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -72,6 +72,35 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(AutomationTriggerEventDefinition))] [JsonSerializable(typeof(AutomationTriggerKind))] [JsonSerializable(typeof(AutomationUpdateRequestedAction))] +[JsonSerializable(typeof(CanvasActionDeclaration))] +[JsonSerializable(typeof(CanvasAvailabilityChangedAction))] +[JsonSerializable(typeof(CanvasAvailabilityState))] +[JsonSerializable(typeof(CanvasAvailabilityStatus))] +[JsonSerializable(typeof(CanvasBlockedTrustState))] +[JsonSerializable(typeof(CanvasCapabilities))] +[JsonSerializable(typeof(CanvasEmptyAvailabilityState))] +[JsonSerializable(typeof(CanvasEntry))] +[JsonSerializable(typeof(CanvasExtensionSource))] +[JsonSerializable(typeof(CanvasFailedAvailabilityState))] +[JsonSerializable(typeof(CanvasIdentity))] +[JsonSerializable(typeof(CanvasIdentityKey))] +[JsonSerializable(typeof(CanvasIncarnationChangedAction))] +[JsonSerializable(typeof(CanvasLoadingAvailabilityState))] +[JsonSerializable(typeof(CanvasNotLoadedAvailabilityState))] +[JsonSerializable(typeof(CanvasPackageSource))] +[JsonSerializable(typeof(CanvasPendingTrustState))] +[JsonSerializable(typeof(CanvasReadyAvailabilityState))] +[JsonSerializable(typeof(CanvasSource))] +[JsonSerializable(typeof(CanvasSourceKind))] +[JsonSerializable(typeof(CanvasSourcePresentation))] +[JsonSerializable(typeof(CanvasState))] +[JsonSerializable(typeof(CanvasTitleChangedAction))] +[JsonSerializable(typeof(CanvasTrustChangedAction))] +[JsonSerializable(typeof(CanvasTrustedState))] +[JsonSerializable(typeof(CanvasTrustState))] +[JsonSerializable(typeof(CanvasTrustStatus))] +[JsonSerializable(typeof(CanvasTypeDeclaration))] +[JsonSerializable(typeof(CanvasUnsupportedAvailabilityState))] [JsonSerializable(typeof(Changeset))] [JsonSerializable(typeof(ChangesetCapabilities))] [JsonSerializable(typeof(ChangesetClearedAction))] @@ -158,6 +187,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ChildCustomization))] [JsonSerializable(typeof(ClientCapabilities))] [JsonSerializable(typeof(ClientPluginCustomization))] +[JsonSerializable(typeof(CloseCanvasParams))] [JsonSerializable(typeof(CompletionItem))] [JsonSerializable(typeof(CompletionItemKind))] [JsonSerializable(typeof(CompletionsParams))] @@ -207,6 +237,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(InitializeParams))] [JsonSerializable(typeof(InitializeResult))] [JsonSerializable(typeof(InputRequestResponsePart))] +[JsonSerializable(typeof(InvokeCanvasActionParams))] +[JsonSerializable(typeof(InvokeCanvasActionResult))] [JsonSerializable(typeof(InvokeChangesetOperationParams))] [JsonSerializable(typeof(InvokeChangesetOperationResult))] [JsonSerializable(typeof(JsonRpcErrorObject))] @@ -217,6 +249,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(JsonRpcSuccessResponse))] [JsonSerializable(typeof(ListAutomationTriggerDefinitionsParams))] [JsonSerializable(typeof(ListAutomationTriggerDefinitionsResult))] +[JsonSerializable(typeof(ListCanvasTypesParams))] +[JsonSerializable(typeof(ListCanvasTypesResult))] [JsonSerializable(typeof(ListSessionsParams))] [JsonSerializable(typeof(ListSessionsResult))] [JsonSerializable(typeof(MarkdownResponsePart))] @@ -244,6 +278,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ModelSelection))] [JsonSerializable(typeof(MultipleChatsCapability))] [JsonSerializable(typeof(MultipleWorkingDirectoriesCapability))] +[JsonSerializable(typeof(OpenCanvasParams))] +[JsonSerializable(typeof(OpenCanvasResult))] [JsonSerializable(typeof(OtlpExportLogsParams))] [JsonSerializable(typeof(OtlpExportMetricsParams))] [JsonSerializable(typeof(OtlpExportTracesParams))] @@ -264,6 +300,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ReconnectResult))] [JsonSerializable(typeof(ReconnectResultType))] [JsonSerializable(typeof(ReconnectSnapshotResult))] +[JsonSerializable(typeof(ResolveCanvasSourceParams))] +[JsonSerializable(typeof(ResolveCanvasSourceResult))] [JsonSerializable(typeof(ResolveSessionConfigParams))] [JsonSerializable(typeof(ResolveSessionConfigResult))] [JsonSerializable(typeof(ResourceChange))] @@ -293,6 +331,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ResourceWriteResult))] [JsonSerializable(typeof(ResponsePart))] [JsonSerializable(typeof(ResponsePartKind))] +[JsonSerializable(typeof(RestartCanvasProviderParams))] [JsonSerializable(typeof(RootActiveSessionsChangedAction))] [JsonSerializable(typeof(RootAgentsChangedAction))] [JsonSerializable(typeof(RootConfigChangedAction))] @@ -307,6 +346,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(SessionActiveClientSetAction))] [JsonSerializable(typeof(SessionActivityChangedAction))] [JsonSerializable(typeof(SessionAddedParams))] +[JsonSerializable(typeof(SessionCanvasRemovedAction))] +[JsonSerializable(typeof(SessionCanvasSetAction))] [JsonSerializable(typeof(SessionChangesetsChangedAction))] [JsonSerializable(typeof(SessionChatAddedAction))] [JsonSerializable(typeof(SessionChatInputRequest))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 5ead9d4c..c75a9813 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -665,6 +665,73 @@ public enum AutomationRunOriginKind Trigger, } +/// Discriminant for {@link CanvasSource} — what kind of package originates a +/// canvas type. +[JsonConverter(typeof(WireEnumConverter))] +public enum CanvasSourceKind +{ + /// An explicitly installed host extension. + [WireValue("extension")] + Extension, + /// An explicitly installed package (not a host extension). + [WireValue("package")] + Package, +} + +/// Discriminant for {@link CanvasTrustState} — whether the host currently +/// permits this canvas's declared actions to execute. +/// +/// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +/// a canvas may be perfectly capable of rendering while blocked from +/// executing actions, and vice versa. Trust decisions are host/runtime +/// authority, not something this protocol grants. +[JsonConverter(typeof(WireEnumConverter))] +public enum CanvasTrustStatus +{ + /// Declared actions may be invoked. + [WireValue("trusted")] + Trusted, + /// A trust decision has not yet been made (e.g. first use of a new/changed source). + [WireValue("pending")] + Pending, + /// The host has denied execution; declared actions MUST NOT be invoked. + [WireValue("blocked")] + Blocked, +} + +/// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +/// live resolution state, independent of its durable +/// {@link CanvasEntry | membership} in a session's catalog. +/// +/// An empty catalog membership list is not itself a close, and a canvas may +/// remain a recorded member while its live availability cycles through these +/// states any number of times (e.g. across provider restarts). +[JsonConverter(typeof(WireEnumConverter))] +public enum CanvasAvailabilityStatus +{ + /// The connected client or host does not support this canvas type (e.g. + /// the client omitted the `canvases` capability, or no local runtime can + /// render this `canvasType`). Distinct from `blocked` trust, which is a + /// policy decision rather than a capability gap. + [WireValue("unsupported")] + Unsupported, + /// Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + [WireValue("notLoaded")] + NotLoaded, + /// Currently resolving or (re)connecting to a live endpoint. + [WireValue("loading")] + Loading, + /// Live and reachable, but the provider has not yet produced content to render. + [WireValue("empty")] + Empty, + /// Live, reachable, and has declared its current actions. + [WireValue("ready")] + Ready, + /// The live endpoint failed to resolve, or resolution otherwise failed. + [WireValue("failed")] + Failed, +} + // ─── Classes ────────────────────────────────────────────────────────── /// An optionally-sized icon that can be displayed in a user interface. @@ -1620,6 +1687,15 @@ public sealed class SessionState [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Changesets { get; set; } + /// Catalog of canvases opened for chats in this session. Presence is + /// durable logical membership, admitted only via `openCanvas` — never + /// implied by a chat's existence or a client's earlier focus. Each entry's + /// {@link CanvasIdentity.chat | `identity.chat`} identifies the exact + /// backing chat; a canvas never migrates to a different chat. See + /// {@link CanvasEntry} for the full membership/availability/trust model. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Canvases { get; set; } + /// Outstanding input the session is blocked on, aggregated across every chat /// so a client can discover and answer it from the session channel alone, /// without subscribing to individual chats. @@ -5457,6 +5533,356 @@ public sealed class AutomationRunState public Dictionary? Meta { get; set; } } +/// A canvas type provided by an installed host extension. +/// +/// `extensionId` is the identity-bearing field for comparison purposes (see +/// {@link CanvasIdentityKey}). `version` is display/informational metadata +/// only — it MUST NOT be treated as identity-bearing (two `CanvasSource` +/// values that differ only in `version` are the same source). +public sealed record CanvasExtensionSource +{ + /// Stable extension identifier (host-defined format, e.g. `publisher.name`). + /// MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string ExtensionId { get; init; } + + /// Installed extension version, when known. Metadata only — not identity-bearing. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } +} + +/// A canvas type provided by an installed package that is not a host +/// extension (e.g. a workspace-declared runtime package). +/// +/// `sourceId` — not `packageName` — is the identity-bearing field: the same +/// declared package name MAY be installed in more than one scope (e.g. a +/// workspace-local copy and a globally-installed copy, or two different +/// registries), and each such installation is a distinct source with its own +/// `sourceId`. `packageName` and `version` are display/informational metadata +/// only and MUST NOT be treated as identity-bearing. +public sealed record CanvasPackageSource +{ + /// Stable, host- or package-manager-assigned unique identifier for this + /// specific installed package instance/scope (opaque format). This is the + /// identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string SourceId { get; init; } + + /// Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + public required string PackageName { get; init; } + + /// Installed package version, when known. Metadata only — not identity-bearing. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } +} + +/// The logical identity of a canvas, excluding the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +/// +/// Two canvases are the same logical canvas iff `chat`, `canvasType`, +/// `instanceId`, and `source`'s **identity-bearing** fields are all equal: +/// `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` +/// plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and +/// `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into +/// this comparison. Clients MUST NOT treat +/// {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — +/// it is only unique within the scope of `(chat, source, canvasType)`. +public sealed record CanvasIdentityKey +{ + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public required string Chat { get; init; } + + /// The extension or package that declares this canvas's type. + public required CanvasSource Source { get; init; } + + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string CanvasType { get; init; } + + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string InstanceId { get; init; } +} + +/// Full identity of a canvas, including the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +public sealed record CanvasIdentity +{ + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public required string Chat { get; init; } + + /// The extension or package that declares this canvas's type. + public required CanvasSource Source { get; init; } + + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string CanvasType { get; init; } + + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string InstanceId { get; init; } + + /// Opaque, host-generated token identifying the current generation of this + /// canvas's live endpoint. The host mints a fresh token whenever a provider + /// restart retires the previous live endpoint and establishes a new one for + /// the same logical instance (see {@link CanvasIncarnationChangedAction | + /// `canvas/incarnationChanged`}); it is not changed by a plain page reload + /// against the same still-live endpoint. + /// + /// `incarnation` is **opaque**: clients and hosts MUST compare it only for + /// equality, never parse it, sort it, or perform arithmetic on it (e.g. it + /// is not guaranteed to be numeric or monotonically increasing). The host + /// MUST NOT reuse a token for this logical identity once it has been + /// superseded, including across a host/process restart — if the host + /// cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + /// or timestamp-derived) that make accidental reuse practically + /// impossible, rather than a small resettable counter. + /// + /// Clients and hosts use `incarnation` to reject stale callbacks and + /// in-flight effects addressed to a superseded endpoint. + public required string Incarnation { get; init; } +} + +public sealed record CanvasTrustedState +{ +} + +public sealed record CanvasPendingTrustState +{ +} + +public sealed record CanvasBlockedTrustState +{ + /// Optional human-readable reason surfaced to the user. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Reason { get; init; } +} + +/// One action a canvas declares it can perform, invoked via +/// `invokeCanvasAction`. +/// +/// Declarations are carried only on the full {@link CanvasState}, loaded when +/// a client subscribes — never duplicated into the lightweight +/// {@link CanvasEntry} catalog entry, keeping session summaries small. +public sealed record CanvasActionDeclaration +{ + /// Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + public required string Id { get; init; } + + /// Human-readable display name. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Description of what invoking the action does. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Inline JSON Schema for the expected `input`, when small enough to embed + /// (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + /// checked by {@link isCanvasSchemaWithinLimits}). Optional because some + /// declared actions take no input. Mutually exclusive with + /// `inputSchemaRef` — a declaration MUST supply at most one of the two. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? InputSchema { get; init; } + + /// Bounded out-of-band reference to a larger JSON Schema, used instead of + /// `inputSchema` when the schema would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. AHP does not mandate a specific resolution mechanism for this + /// URI (e.g. a host MAY make it `resourceRead`-able). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InputSchemaRef { get; init; } +} + +public sealed record CanvasUnsupportedAvailabilityState +{ +} + +public sealed record CanvasNotLoadedAvailabilityState +{ +} + +public sealed record CanvasLoadingAvailabilityState +{ +} + +public sealed record CanvasEmptyAvailabilityState +{ +} + +public sealed record CanvasReadyAvailabilityState +{ + /// Actions currently declared by the live provider (full replacement each time this state is produced). + public required List Actions { get; init; } +} + +public sealed record CanvasFailedAvailabilityState +{ + /// Stable machine-readable and human-readable failure information. + public required ErrorInfo Error { get; init; } +} + +/// Lightweight catalog entry for a canvas, carried in +/// {@link SessionState.canvases | `SessionState.canvases`}. Presence +/// represents durable **logical membership** — it is unaffected by the live +/// {@link CanvasEntry.availability | `availability`} cycling through +/// `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. +/// +/// The full state, including declared actions, lives in {@link CanvasState}, +/// loaded when a client subscribes to {@link CanvasEntry.resource}. +public sealed class CanvasEntry +{ + /// Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + public required string Resource { get; set; } + + /// Full identity, including current incarnation. + public required CanvasIdentity Identity { get; set; } + + /// Human-readable display title. + public required string Title { get; set; } + + /// Optional display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; set; } + + /// Current trust decision matching {@link CanvasState.trust}. + public required CanvasTrustState Trust { get; set; } + + /// Current availability status matching {@link CanvasState.availability}'s discriminant. + public CanvasAvailabilityStatus Availability { get; set; } + + /// Monotonically increasing counter bumped on every change to this + /// canvas's state (trust, availability, or incarnation). Clients MAY use it + /// to detect and reject stale reads without a full deep comparison. + public long Revision { get; set; } + + /// Opaque host-defined summary metadata. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// Full state for a single canvas, loaded when a client subscribes to the +/// canvas's URI. +/// +/// `CanvasState` **denormalizes** every {@link CanvasEntry} field directly +/// onto itself, replacing `availability`'s lightweight status with the full +/// {@link CanvasAvailabilityState} (including declared actions or failure +/// detail). Producers MUST keep the two representations consistent: any +/// change to the inlined fields SHOULD also be announced on the owning +/// session via {@link SessionCanvasSetAction | `session/canvasSet`}. +public sealed class CanvasState +{ + /// URI of this canvas channel. + public required string Resource { get; set; } + + /// Full identity, including current incarnation. + public required CanvasIdentity Identity { get; set; } + + /// Human-readable display title. + public required string Title { get; set; } + + /// Optional display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; set; } + + /// Current trust decision. + public required CanvasTrustState Trust { get; set; } + + /// Current live resolution state. + public required CanvasAvailabilityState Availability { get; set; } + + /// Matches {@link CanvasEntry.revision}. + public long Revision { get; set; } + + /// Opaque host-defined metadata. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// A canvas type an installed extension or package currently makes available +/// to open for a chat, as returned by `listCanvasTypes`. +/// +/// `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is +/// unrelated to {@link CanvasEntry}, which represents durable membership of +/// an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the +/// catalogue (via `listCanvasTypes`) never opens, materializes, or restarts +/// anything; only `openCanvas` does. +public sealed record CanvasTypeDeclaration +{ + /// The extension or package that declares this canvas type. + public required CanvasSource Source { get; init; } + + /// Provider-declared canvas type (host/provider-defined format), passed as + /// {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string CanvasType { get; init; } + + /// Human-readable display name for a canvas-type picker. + public required string Title { get; init; } + + /// Description of what this canvas type does. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Optional display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; init; } + + /// Inline JSON Schema describing the `openCanvas` `input` this type + /// expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + /// / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + /// `openInputSchemaRef`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? OpenInputSchema { get; init; } + + /// Bounded out-of-band reference to a larger open-input JSON Schema, used + /// instead of `openInputSchema` when it would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OpenInputSchemaRef { get; init; } + + /// Advisory, statically-known preview of actions this canvas type + /// typically declares once opened (bounded to + /// {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + /// the actual invocable actions for an opened instance are always + /// {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + /// depend on live provider configuration) and MUST be used instead of this + /// preview once the canvas is open. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? DeclaredActions { get; init; } +} + +/// Transient, renderer-neutral presentation of a canvas's current live +/// endpoint, returned by `resolveCanvasSource`. +/// +/// This is a plain URL, not any renderer- or process-model-specific handle +/// (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview +/// panel reference) — how a client actually presents it (a VS Code Webview, +/// the Integrated Browser, or otherwise) is entirely a client/host +/// implementation detail outside this protocol. +public sealed record CanvasSourcePresentation +{ + /// Ephemeral URL to the canvas's current live endpoint. Transient — MUST + /// NOT be persisted, cached beyond the current read, or treated as a + /// stable/durable identity. A host MAY embed short-lived, single-use + /// credentials in it; such credentials are never durable authority. + public required string Url { get; init; } + + /// Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ExpiresAt { get; init; } +} + // ─── Discriminated Unions ───────────────────────────────────────────── /// A single explicit customization enablement decision. @@ -6135,6 +6561,92 @@ public AutomationRunLifecycleConverter() } } +/// CanvasSource identifies the explicitly installed extension or package that declares a canvas type. +[JsonConverter(typeof(CanvasSourceConverter))] +public sealed class CanvasSource : AhpUnion +{ + /// Creates an empty CanvasSource (no active variant). + public CanvasSource() { } + + /// Creates a CanvasSource wrapping the given variant value. + public CanvasSource(object? value) : base(value) { } +} + +/// System.Text.Json converter for the CanvasSource discriminated union. +internal sealed class CanvasSourceConverter : UnionConverter +{ + public CanvasSourceConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["extension"] = typeof(CanvasExtensionSource), + ["package"] = typeof(CanvasPackageSource), + }, + allowUnknown: false) + { + } +} + +/// CanvasTrustState is the current trust decision governing whether a canvas's declared actions may execute. +[JsonConverter(typeof(CanvasTrustStateConverter))] +public sealed class CanvasTrustState : AhpUnion +{ + /// Creates an empty CanvasTrustState (no active variant). + public CanvasTrustState() { } + + /// Creates a CanvasTrustState wrapping the given variant value. + public CanvasTrustState(object? value) : base(value) { } +} + +/// System.Text.Json converter for the CanvasTrustState discriminated union. +internal sealed class CanvasTrustStateConverter : UnionConverter +{ + public CanvasTrustStateConverter() + : base( + discriminator: "status", + variants: new Dictionary + { + ["trusted"] = typeof(CanvasTrustedState), + ["pending"] = typeof(CanvasPendingTrustState), + ["blocked"] = typeof(CanvasBlockedTrustState), + }, + allowUnknown: false) + { + } +} + +/// CanvasAvailabilityState is the current live resolution state of a canvas. +[JsonConverter(typeof(CanvasAvailabilityStateConverter))] +public sealed class CanvasAvailabilityState : AhpUnion +{ + /// Creates an empty CanvasAvailabilityState (no active variant). + public CanvasAvailabilityState() { } + + /// Creates a CanvasAvailabilityState wrapping the given variant value. + public CanvasAvailabilityState(object? value) : base(value) { } +} + +/// System.Text.Json converter for the CanvasAvailabilityState discriminated union. +internal sealed class CanvasAvailabilityStateConverter : UnionConverter +{ + public CanvasAvailabilityStateConverter() + : base( + discriminator: "status", + variants: new Dictionary + { + ["unsupported"] = typeof(CanvasUnsupportedAvailabilityState), + ["notLoaded"] = typeof(CanvasNotLoadedAvailabilityState), + ["loading"] = typeof(CanvasLoadingAvailabilityState), + ["empty"] = typeof(CanvasEmptyAvailabilityState), + ["ready"] = typeof(CanvasReadyAvailabilityState), + ["failed"] = typeof(CanvasFailedAvailabilityState), + }, + allowUnknown: false) + { + } +} + /// /// ChatOrigin describes how a chat came into existence. /// diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 1f8c3437..0418dbbb 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,10 +14,11 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "0.9.0"; + public const string Current = "0.10.0"; private static readonly string[] s_supported = { + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs index bfaa9f2c..32c6ffaa 100644 --- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -55,6 +55,18 @@ public static bool TryGetActionType(object action, out ActionType actionType) case AutomationUpdateRequestedAction value: actionType = value.Type; return true; + case CanvasAvailabilityChangedAction value: + actionType = value.Type; + return true; + case CanvasIncarnationChangedAction value: + actionType = value.Type; + return true; + case CanvasTitleChangedAction value: + actionType = value.Type; + return true; + case CanvasTrustChangedAction value: + actionType = value.Type; + return true; case ChangesetClearedAction value: actionType = value.Type; return true; @@ -193,6 +205,12 @@ public static bool TryGetActionType(object action, out ActionType actionType) case SessionActivityChangedAction value: actionType = value.Type; return true; + case SessionCanvasRemovedAction value: + actionType = value.Type; + return true; + case SessionCanvasSetAction value: + actionType = value.Type; + return true; case SessionChangesetsChangedAction value: actionType = value.Type; return true; @@ -381,6 +399,10 @@ public static string GetWireName(ActionType actionType) => ActionType.AutomationRunSessionSet => "automationRun/sessionSet", ActionType.AutomationSet => "automation/set", ActionType.AutomationUpdateRequested => "automation/updateRequested", + ActionType.CanvasAvailabilityChanged => "canvas/availabilityChanged", + ActionType.CanvasIncarnationChanged => "canvas/incarnationChanged", + ActionType.CanvasTitleChanged => "canvas/titleChanged", + ActionType.CanvasTrustChanged => "canvas/trustChanged", ActionType.ChangesetCleared => "changeset/cleared", ActionType.ChangesetContentChanged => "changeset/contentChanged", ActionType.ChangesetFileRemoved => "changeset/fileRemoved", @@ -427,6 +449,8 @@ public static string GetWireName(ActionType actionType) => ActionType.SessionActiveClientRemoved => "session/activeClientRemoved", ActionType.SessionActiveClientSet => "session/activeClientSet", ActionType.SessionActivityChanged => "session/activityChanged", + ActionType.SessionCanvasRemoved => "session/canvasRemoved", + ActionType.SessionCanvasSet => "session/canvasSet", ActionType.SessionChangesetsChanged => "session/changesetsChanged", ActionType.SessionChatAdded => "session/chatAdded", ActionType.SessionChatRemoved => "session/chatRemoved", diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index d940c8bd..7cc250e4 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -115,6 +115,12 @@ const ( ActionTypeAutomationRunSessionRemoved ActionType = "automationRun/sessionRemoved" ActionTypeAutomationRunPrimarySessionChanged ActionType = "automationRun/primarySessionChanged" ActionTypeAutomationRunCancelRequested ActionType = "automationRun/cancelRequested" + ActionTypeSessionCanvasSet ActionType = "session/canvasSet" + ActionTypeSessionCanvasRemoved ActionType = "session/canvasRemoved" + ActionTypeCanvasAvailabilityChanged ActionType = "canvas/availabilityChanged" + ActionTypeCanvasTrustChanged ActionType = "canvas/trustChanged" + ActionTypeCanvasIncarnationChanged ActionType = "canvas/incarnationChanged" + ActionTypeCanvasTitleChanged ActionType = "canvas/titleChanged" ) // ─── Action Envelope ───────────────────────────────────────────────── @@ -1651,6 +1657,90 @@ type AutomationRunCancelRequestedAction struct { Type ActionType `json:"type"` } +// A canvas was admitted (opened) or its catalog entry changed. +// +// Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the +// server dispatches this with the full entry to record a newly opened +// canvas, or to republish it after a trust/availability/incarnation change +// so subscribers following only the session channel stay in sync with +// {@link CanvasState}. Never client-dispatchable — canvases are admitted +// only through the `openCanvas` command. A stale/out-of-order delivery +// (`canvas.revision` not strictly greater than the currently-recorded +// entry's revision) MUST be rejected (no-op) rather than overwrite a newer +// entry with older data. +type SessionCanvasSetAction struct { + Type ActionType `json:"type"` + // The canvas entry to add or update, matched by `resource`. + Canvas CanvasEntry `json:"canvas"` +} + +// A canvas was logically closed. +// +// Remove semantics keyed by `resource`: an unknown URI is a no-op. This +// represents durable membership removal, not a client hiding a local +// tab/view — see `closeCanvas`. +type SessionCanvasRemovedAction struct { + Type ActionType `json:"type"` + // Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + Resource URI `json:"resource"` +} + +// Replaces the canvas's live resolution state. +// +// Dispatched by the host on every availability transition: initial +// resolution after `openCanvas`, provider restart, reload, and failure. +type CanvasAvailabilityChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasState.availability}. + Availability CanvasAvailabilityState `json:"availability"` + // The {@link CanvasState.revision} this action results in. The reducer + // MUST reject (no-op) this action if `revision` is not strictly greater + // than the canvas's current `revision` — this is how stale/out-of-order + // deliveries are consistently rejected across every canvas action, not + // just this one. + Revision int64 `json:"revision"` +} + +// Replaces the canvas's trust decision. +// +// Dispatched by the host whenever the execution-trust decision for this +// canvas's declared actions changes (e.g. a pending decision resolves, or an +// administrator revokes a previously trusted source). +type CanvasTrustChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasState.trust}. + Trust CanvasTrustState `json:"trust"` + // The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + Revision int64 `json:"revision"` +} + +// Records that the canvas's live endpoint was replaced by a fresh one for +// the same logical instance (e.g. the owning provider restarted). +// +// The host MUST dispatch {@link CanvasAvailabilityChangedAction} to +// transition through `notLoaded`/`loading` around this change. Receivers +// MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed +// callbacks addressed to a superseded `incarnation` — because `incarnation` +// is opaque (see {@link CanvasIdentity.incarnation}), that rejection is +// driven by the accompanying `revision` bump here, not by comparing +// `incarnation` values for order. +type CanvasIncarnationChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + Incarnation string `json:"incarnation"` + // The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + Revision int64 `json:"revision"` +} + +// Replaces the canvas's display title. +type CanvasTitleChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasState.title}. + Title string `json:"title"` + // The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + Revision int64 `json:"revision"` +} + // ─── StateAction Union ─────────────────────────────────────────────── // StateAction is the discriminated union of every state action. @@ -1758,6 +1848,12 @@ func (*AutomationRunSessionSetAction) isStateAction() {} func (*AutomationRunSessionRemovedAction) isStateAction() {} func (*AutomationRunPrimarySessionChangedAction) isStateAction() {} func (*AutomationRunCancelRequestedAction) isStateAction() {} +func (*SessionCanvasSetAction) isStateAction() {} +func (*SessionCanvasRemovedAction) isStateAction() {} +func (*CanvasAvailabilityChangedAction) isStateAction() {} +func (*CanvasTrustChangedAction) isStateAction() {} +func (*CanvasIncarnationChangedAction) isStateAction() {} +func (*CanvasTitleChangedAction) isStateAction() {} // StateActionUnknown carries an unrecognized StateAction variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. type StateActionUnknown struct { @@ -2349,6 +2445,42 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "session/canvasSet": + var value SessionCanvasSetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "session/canvasRemoved": + var value SessionCanvasRemovedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/availabilityChanged": + var value CanvasAvailabilityChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/trustChanged": + var value CanvasTrustChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/incarnationChanged": + var value CanvasIncarnationChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/titleChanged": + var value CanvasTitleChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value default: raw := make(json.RawMessage, len(data)) copy(raw, data) diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 9ef81b6f..5ec00f44 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -180,6 +180,22 @@ type InitializeResult struct { // `ahp-automations://` for {@link AutomationState}; absence means the // host does not expose an automation catalogue or automation commands. Automations *AutomationCapabilities `json:"automations,omitempty"` + // Host/runtime-owned local-canvas support. Presence means the SERVER + // currently has a working runtime able to serve `openCanvas` / + // `invokeCanvasAction` for at least one qualifying (explicitly installed + // and trust-eligible) extension/package source; absence means the host + // has no available canvas runtime, and clients MUST treat every canvas as + // {@link CanvasAvailabilityStatus.Unsupported} regardless of what + // {@link ClientCapabilities.canvases} declared. + // + // **Protocol version support alone is not a runtime capability**: a host + // speaking protocol `>= 0.10.0` without this field present MUST NOT be + // assumed to have a usable canvas runtime. This field — not the + // negotiated `protocolVersion` — is the authoritative signal, and is + // independent of any individual canvas's live availability + // ({@link CanvasAvailabilityState}) or trust decision + // ({@link CanvasTrustState}). + Canvases *CanvasCapabilities `json:"canvases,omitempty"` } // Optional capabilities a client declares during `initialize`. @@ -200,6 +216,23 @@ type ClientCapabilities struct { // capability is declared. Clients that omit it MUST treat // App-bearing tool calls as ordinary MCP tool calls. McpApps map[string]json.RawMessage `json:"mcpApps,omitempty"` + // Client can render local canvases: `listCanvasTypes`, `openCanvas`, + // subscribe to the resulting `ahp-canvas:` channel, and drive + // `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + // `closeCanvas`. + // + // Hosts SHOULD NOT offer canvas admission to a client that omits this + // capability; such a client MUST be treated as if every canvas were + // {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + // anything about server/runtime execution trust — see + // {@link CanvasTrustStatus}, which is a separate, host-owned decision. + // + // This declares only the CLIENT's rendering capability. Protocol version + // support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + // actually has a working canvas runtime — see + // {@link InitializeResult.canvases}, the server-side counterpart, which a + // client MUST also check before treating canvases as usable. + Canvases map[string]json.RawMessage `json:"canvases,omitempty"` } // Automation features supported by this host authority. @@ -225,6 +258,12 @@ type AutomationCapabilities struct { RunHistoryLimit *int64 `json:"runHistoryLimit,omitempty"` } +// Local-canvas runtime features supported by this host authority. The empty +// object means "supported" — see {@link InitializeResult.canvases} for what +// presence/absence of this field itself means. +type CanvasCapabilities struct { +} + // Presence capability for {@link AutomationCreateRequestedAction | // `automation/createRequested`}. // @@ -1314,6 +1353,262 @@ type FetchAutomationRunsParams struct { type FetchAutomationRunsResult struct { } +// Discovers canvas TYPES currently available to open for one exact backing +// chat. +// +// This is a **pure read/browse** operation: it MUST NOT open, materialize, +// or otherwise admit any canvas — see `openCanvas` for that. It is +// unrelated to {@link SessionState.canvases}, which reflects durable +// membership of already-opened canvas INSTANCES, not the set of canvas +// TYPES a host/extension could open; do not confuse the two. +type ListCanvasTypesParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Maximum number of entries to return in this page. The server SHOULD respect + // this bound but MAY return fewer entries and MAY impose its own upper cap. + // Omit to let the server choose the page size. + Limit *int64 `json:"limit,omitempty"` + // Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + // Omit to fetch the first page. Cursors are server-defined and MUST be treated + // as opaque — do not parse, modify, or persist them across connections. An + // unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + Cursor *string `json:"cursor,omitempty"` +} + +// Available canvas types for the requested chat. +type ListCanvasTypesResult struct { + // Opaque cursor for the next page. Present when more entries exist beyond the + // returned page; absent signals the end of the collection. Pass it back as + // {@link PaginatedParams.cursor} to fetch the following page. + NextCursor *string `json:"nextCursor,omitempty"` + // Discovered canvas type declarations. + Types []CanvasTypeDeclaration `json:"types"` +} + +// Explicitly opens (admits) a canvas, associating it with the owning chat +// given by `identity.chat` at the moment of the call — never with whichever +// chat later happens to have focus. +// +// This is a read-write admission, not a resolve: unlike `subscribe` (which +// only reads current state), `openCanvas` is the operation that creates +// durable membership. There is no implicit open — a client MUST call this +// before a canvas appears in {@link SessionState.canvases}. Once admitted, +// clients read and follow live state by `subscribe`-ing to the returned +// `canvas.resource`, and resolve the current live endpoint via +// `resolveCanvasSource`; neither read itself opens, resumes, or restarts +// anything. +// +// **Logical identity is always singular.** The same {@link CanvasIdentityKey} +// (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same +// `canvas` resource URI and the same {@link SessionState.canvases} catalog +// entry, no matter how many times `openCanvas` is called for it — the server +// MUST return that existing entry's `resource` rather than mint a second +// one. A client-supplied `canvas` URI is honored only on the call that first +// establishes the identity; on a later call for an already-recorded +// identity the server MUST ignore the supplied `canvas` value and return the +// existing resource instead. +// +// **Idempotency is scoped to `requestId`, not identity.** Retrying with the +// exact same `requestId` and byte-for-byte identical params from the same +// authenticated connection MUST return the original result without +// repeating any side effect, within a bounded live window (the server is +// not required to remember it forever). Reusing the same `requestId` with +// any different parameter value MUST be rejected with `Conflict` +// (`-32011`) — mint a new `requestId` for a new logical call. A genuinely +// NEW `requestId` for an already-open identity MAY be effectful (e.g. +// updating `title`/`icon`, or causing the provider to re-run its own +// open-time initialization with new `input`) — this mirrors the pinned +// SDK's own repeated-open behavior and does not create a second logical +// identity. There is no exactly-once-across-crash guarantee: a lost reply +// is indeterminate, and clients MUST NOT automatically replay `openCanvas` +// — reconnect and read `SessionState.canvases` / `resolveCanvasSource` +// instead to determine the actual outcome. +type OpenCanvasParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + Canvas URI `json:"canvas"` + // Logical identity to open or re-admit. + Identity CanvasIdentityKey `json:"identity"` + // Initial (or updated, on a later effectful call) display title. + Title string `json:"title"` + // Initial (or updated) display icon. + Icon *Icon `json:"icon,omitempty"` + // Bounded JSON input for this open call (e.g. seed parameters the + // provider uses to initialize the canvas), opaque to the protocol. See + // {@link CanvasTypeDeclaration.openInputSchema} / + // `openInputSchemaRef` for the expected shape. The JSON-serialized value + // MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + Input *json.RawMessage `json:"input,omitempty"` + // Durable client-generated idempotency key bounding retry deduplication + // for this call within a live window; see the idempotency rules above. + // MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` +} + +// Result identifying the existing or newly opened canvas. +type OpenCanvasResult struct { + // The catalog entry for the opened (or already-open) canvas. + Canvas CanvasEntry `json:"canvas"` +} + +// Pure, read-only read of a canvas's current live-resolution state and, +// when currently live, a transient endpoint presentation. +// +// This MUST NOT create, resume, reopen, or restart a provider. If the +// canvas does not currently have a live endpoint, `source` is absent and +// `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`) — +// call `restartCanvasProvider` (an explicitly effectful operation) to +// attempt recovery instead. A client-local page reload (re-navigating the +// client's own rendering surface to the same still-live `source.url`) +// needs no dedicated command at all; calling `resolveCanvasSource` again is +// also how a client retries resolving a currently-unavailable source +// without restarting anything. +type ResolveCanvasSourceParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// The canvas's current live-resolution state as of this read. +type ResolveCanvasSourceResult struct { + // Current {@link CanvasEntry.availability}. + Availability CanvasAvailabilityStatus `json:"availability"` + // Current {@link CanvasIdentity.incarnation}. + Incarnation string `json:"incarnation"` + // Current {@link CanvasEntry.revision}. + Revision int64 `json:"revision"` + // Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + Source *CanvasSourcePresentation `json:"source,omitempty"` +} + +// Invokes one of a canvas's currently declared actions exactly once. +// +// The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's +// current trust is not `trusted`, and with `NotFound` (`-32008`) if +// `actionId` does not match a currently declared action. `incarnation` is +// REQUIRED — omitting stale-generation protection on an effectful call is +// not allowed. If it does not match the canvas's current +// {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` +// (`-32011`) rather than route the call to a superseded endpoint. +// +// The result is the provider's raw reply and is never persisted into +// `CanvasState` — large or provider-specific payloads stay off the durable +// state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be +// represented out of band instead of being returned inline. Any resulting +// state changes (e.g. a subsequent availability transition) flow back +// separately through the normal `canvas/*` action stream on the canvas's +// own channel. +// +// A lost reply (e.g. a dropped connection after the provider already ran +// the handler) is **indeterminate**: clients MUST NOT automatically replay +// `invokeCanvasAction` on reconnect. Instead, reconnect and read the +// canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and +// decide from observed `revision`/`incarnation` and any provider-visible +// side effect whether to surface the ambiguity to the user, rather than +// assuming success or failure. +type InvokeCanvasActionParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + ActionId string `json:"actionId"` + // Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + // if any. The JSON-serialized value MUST NOT exceed + // `CANVAS_INPUT_MAX_LENGTH`. + Input *json.RawMessage `json:"input,omitempty"` + // Expected {@link CanvasIdentity.incarnation}. Required — see above. The + // server MUST reject the call with `Conflict` if the canvas's live + // endpoint has since been superseded, rather than deliver the call to it. + Incarnation string `json:"incarnation"` + // Durable client-generated idempotency key bounding retry + // deduplication for this invocation within a live window. The server is + // not required to guarantee exactly-once execution across a crash. MUST + // NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` +} + +// Result of invoking a declared canvas action. +type InvokeCanvasActionResult struct { + // The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + Result json.RawMessage `json:"result"` +} + +// Explicitly restarts the provider/chat-scoped runtime backing this canvas: +// retires the current live endpoint and establishes a fresh one for the +// same logical instance. +// +// This is the **only** operation that intentionally causes an +// {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` +// (read-only source resolution / client-local page reload) MUST NEVER +// trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} +// (transitioning through `notLoaded`/`loading`) and then +// {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart +// never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or +// restore any prior in-flight effect. +// +// `incarnation` is REQUIRED: the server MUST reject with `Conflict` +// (`-32011`) if it does not match the canvas's current +// {@link CanvasIdentity.incarnation}, so a caller cannot restart a +// generation it never observed (e.g. after racing a concurrent restart). A +// lost reply is indeterminate; clients MUST NOT automatically replay this +// command — reconnect and compare the canvas's current `incarnation` +// instead. +type RestartCanvasProviderParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Durable client-generated idempotency key, following the same + // requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + // `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` + // Expected current {@link CanvasIdentity.incarnation}; required — see above. + Incarnation string `json:"incarnation"` +} + +// Logically closes a canvas: removes its durable membership from +// `SessionState.canvases` and disposes matching views. +// +// This is distinct from a client merely hiding a local tab or view, which is +// presentation-only and MUST NOT dispatch this command. There is no +// advertised model tool for this operation — it is invoked only by +// UI/RPC callers. +// +// `revision` is REQUIRED: the server MUST reject with `Conflict` +// (`-32011`) if it does not match the canvas's current +// {@link CanvasEntry.revision}, so a caller cannot close membership state it +// never actually observed. If no matching entry exists (e.g. already +// closed), the server MUST treat this as a successful no-op rather than an +// error — the `revision` precondition only applies when an entry still +// exists. A lost reply is indeterminate; clients MUST NOT automatically +// replay this command — reconnect and check `SessionState.canvases` +// instead. +type CloseCanvasParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Durable client-generated idempotency key, following the same + // requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + // `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` + // Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + Revision int64 `json:"revision"` +} + func (v *ForkChatSource) UnmarshalJSON(data []byte) error { disc, ok, err := readDiscriminator(data, "kind") if err != nil { diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 269b50f5..e5738215 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -540,6 +540,62 @@ const ( AutomationRunOriginKindTrigger AutomationRunOriginKind = "trigger" ) +// Discriminant for {@link CanvasSource} — what kind of package originates a +// canvas type. +type CanvasSourceKind string + +const ( + // An explicitly installed host extension. + CanvasSourceKindExtension CanvasSourceKind = "extension" + // An explicitly installed package (not a host extension). + CanvasSourceKindPackage CanvasSourceKind = "package" +) + +// Discriminant for {@link CanvasTrustState} — whether the host currently +// permits this canvas's declared actions to execute. +// +// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +// a canvas may be perfectly capable of rendering while blocked from +// executing actions, and vice versa. Trust decisions are host/runtime +// authority, not something this protocol grants. +type CanvasTrustStatus string + +const ( + // Declared actions may be invoked. + CanvasTrustStatusTrusted CanvasTrustStatus = "trusted" + // A trust decision has not yet been made (e.g. first use of a new/changed source). + CanvasTrustStatusPending CanvasTrustStatus = "pending" + // The host has denied execution; declared actions MUST NOT be invoked. + CanvasTrustStatusBlocked CanvasTrustStatus = "blocked" +) + +// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +// live resolution state, independent of its durable +// {@link CanvasEntry | membership} in a session's catalog. +// +// An empty catalog membership list is not itself a close, and a canvas may +// remain a recorded member while its live availability cycles through these +// states any number of times (e.g. across provider restarts). +type CanvasAvailabilityStatus string + +const ( + // The connected client or host does not support this canvas type (e.g. + // the client omitted the `canvases` capability, or no local runtime can + // render this `canvasType`). Distinct from `blocked` trust, which is a + // policy decision rather than a capability gap. + CanvasAvailabilityStatusUnsupported CanvasAvailabilityStatus = "unsupported" + // Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + CanvasAvailabilityStatusNotLoaded CanvasAvailabilityStatus = "notLoaded" + // Currently resolving or (re)connecting to a live endpoint. + CanvasAvailabilityStatusLoading CanvasAvailabilityStatus = "loading" + // Live and reachable, but the provider has not yet produced content to render. + CanvasAvailabilityStatusEmpty CanvasAvailabilityStatus = "empty" + // Live, reachable, and has declared its current actions. + CanvasAvailabilityStatusReady CanvasAvailabilityStatus = "ready" + // The live endpoint failed to resolve, or resolution otherwise failed. + CanvasAvailabilityStatusFailed CanvasAvailabilityStatus = "failed" +) + // ─── Structs ────────────────────────────────────────────────────────── // An optionally-sized icon that can be displayed in a user interface. @@ -933,6 +989,13 @@ type SessionState struct { // before subscribing. See {@link Changeset} for the full shape and // {@link /guide/changesets | Changesets} for an overview of the model. Changesets []Changeset `json:"changesets,omitempty"` + // Catalog of canvases opened for chats in this session. Presence is + // durable logical membership, admitted only via `openCanvas` — never + // implied by a chat's existence or a client's earlier focus. Each entry's + // {@link CanvasIdentity.chat | `identity.chat`} identifies the exact + // backing chat; a canvas never migrates to a different chat. See + // {@link CanvasEntry} for the full membership/availability/trust model. + Canvases []CanvasEntry `json:"canvases,omitempty"` // Outstanding input the session is blocked on, aggregated across every chat // so a client can discover and answer it from the session channel alone, // without subscribing to individual chats. @@ -4099,6 +4162,283 @@ type AutomationRunState struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// A canvas type provided by an installed host extension. +// +// `extensionId` is the identity-bearing field for comparison purposes (see +// {@link CanvasIdentityKey}). `version` is display/informational metadata +// only — it MUST NOT be treated as identity-bearing (two `CanvasSource` +// values that differ only in `version` are the same source). +type CanvasExtensionSource struct { + // Stable extension identifier (host-defined format, e.g. `publisher.name`). + // MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + ExtensionId string `json:"extensionId"` + // Installed extension version, when known. Metadata only — not identity-bearing. + Version *string `json:"version,omitempty"` +} + +// A canvas type provided by an installed package that is not a host +// extension (e.g. a workspace-declared runtime package). +// +// `sourceId` — not `packageName` — is the identity-bearing field: the same +// declared package name MAY be installed in more than one scope (e.g. a +// workspace-local copy and a globally-installed copy, or two different +// registries), and each such installation is a distinct source with its own +// `sourceId`. `packageName` and `version` are display/informational metadata +// only and MUST NOT be treated as identity-bearing. +type CanvasPackageSource struct { + // Stable, host- or package-manager-assigned unique identifier for this + // specific installed package instance/scope (opaque format). This is the + // identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + SourceId string `json:"sourceId"` + // Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + PackageName string `json:"packageName"` + // Installed package version, when known. Metadata only — not identity-bearing. + Version *string `json:"version,omitempty"` +} + +// The logical identity of a canvas, excluding the host-assigned +// {@link CanvasIdentity.incarnation | `incarnation`}. +// +// Two canvases are the same logical canvas iff `chat`, `canvasType`, +// `instanceId`, and `source`'s **identity-bearing** fields are all equal: +// `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` +// plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and +// `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into +// this comparison. Clients MUST NOT treat +// {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — +// it is only unique within the scope of `(chat, source, canvasType)`. +type CanvasIdentityKey struct { + // The exact backing chat this canvas belongs to. A canvas is never + // re-associated with a different chat; opening a new one for another chat + // creates a distinct canvas. + Chat URI `json:"chat"` + // The extension or package that declares this canvas's type. + Source CanvasSource `json:"source"` + // Provider-declared canvas type (host/provider-defined format). MUST NOT + // exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + CanvasType string `json:"canvasType"` + // Provider-chosen stable identifier for this canvas instance, scoped to + // `(chat, source, canvasType)`. Stable across reloads and host/window + // restarts for the same logical canvas. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + InstanceId string `json:"instanceId"` +} + +// Full identity of a canvas, including the host-assigned +// {@link CanvasIdentity.incarnation | `incarnation`}. +type CanvasIdentity struct { + // The exact backing chat this canvas belongs to. A canvas is never + // re-associated with a different chat; opening a new one for another chat + // creates a distinct canvas. + Chat URI `json:"chat"` + // The extension or package that declares this canvas's type. + Source CanvasSource `json:"source"` + // Provider-declared canvas type (host/provider-defined format). MUST NOT + // exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + CanvasType string `json:"canvasType"` + // Provider-chosen stable identifier for this canvas instance, scoped to + // `(chat, source, canvasType)`. Stable across reloads and host/window + // restarts for the same logical canvas. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + InstanceId string `json:"instanceId"` + // Opaque, host-generated token identifying the current generation of this + // canvas's live endpoint. The host mints a fresh token whenever a provider + // restart retires the previous live endpoint and establishes a new one for + // the same logical instance (see {@link CanvasIncarnationChangedAction | + // `canvas/incarnationChanged`}); it is not changed by a plain page reload + // against the same still-live endpoint. + // + // `incarnation` is **opaque**: clients and hosts MUST compare it only for + // equality, never parse it, sort it, or perform arithmetic on it (e.g. it + // is not guaranteed to be numeric or monotonically increasing). The host + // MUST NOT reuse a token for this logical identity once it has been + // superseded, including across a host/process restart — if the host + // cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + // or timestamp-derived) that make accidental reuse practically + // impossible, rather than a small resettable counter. + // + // Clients and hosts use `incarnation` to reject stale callbacks and + // in-flight effects addressed to a superseded endpoint. + Incarnation string `json:"incarnation"` +} + +type CanvasTrustedState struct { +} + +type CanvasPendingTrustState struct { +} + +type CanvasBlockedTrustState struct { + // Optional human-readable reason surfaced to the user. + Reason *string `json:"reason,omitempty"` +} + +// One action a canvas declares it can perform, invoked via +// `invokeCanvasAction`. +// +// Declarations are carried only on the full {@link CanvasState}, loaded when +// a client subscribes — never duplicated into the lightweight +// {@link CanvasEntry} catalog entry, keeping session summaries small. +type CanvasActionDeclaration struct { + // Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + Id string `json:"id"` + // Human-readable display name. + Title *string `json:"title,omitempty"` + // Description of what invoking the action does. + Description *string `json:"description,omitempty"` + // Inline JSON Schema for the expected `input`, when small enough to embed + // (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + // checked by {@link isCanvasSchemaWithinLimits}). Optional because some + // declared actions take no input. Mutually exclusive with + // `inputSchemaRef` — a declaration MUST supply at most one of the two. + InputSchema *json.RawMessage `json:"inputSchema,omitempty"` + // Bounded out-of-band reference to a larger JSON Schema, used instead of + // `inputSchema` when the schema would exceed + // {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + // inlined. AHP does not mandate a specific resolution mechanism for this + // URI (e.g. a host MAY make it `resourceRead`-able). + InputSchemaRef *URI `json:"inputSchemaRef,omitempty"` +} + +type CanvasUnsupportedAvailabilityState struct { +} + +type CanvasNotLoadedAvailabilityState struct { +} + +type CanvasLoadingAvailabilityState struct { +} + +type CanvasEmptyAvailabilityState struct { +} + +type CanvasReadyAvailabilityState struct { + // Actions currently declared by the live provider (full replacement each time this state is produced). + Actions []CanvasActionDeclaration `json:"actions"` +} + +type CanvasFailedAvailabilityState struct { + // Stable machine-readable and human-readable failure information. + Error ErrorInfo `json:"error"` +} + +// Lightweight catalog entry for a canvas, carried in +// {@link SessionState.canvases | `SessionState.canvases`}. Presence +// represents durable **logical membership** — it is unaffected by the live +// {@link CanvasEntry.availability | `availability`} cycling through +// `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. +// +// The full state, including declared actions, lives in {@link CanvasState}, +// loaded when a client subscribes to {@link CanvasEntry.resource}. +type CanvasEntry struct { + // Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + Resource URI `json:"resource"` + // Full identity, including current incarnation. + Identity CanvasIdentity `json:"identity"` + // Human-readable display title. + Title string `json:"title"` + // Optional display icon. + Icon *Icon `json:"icon,omitempty"` + // Current trust decision matching {@link CanvasState.trust}. + Trust CanvasTrustState `json:"trust"` + // Current availability status matching {@link CanvasState.availability}'s discriminant. + Availability CanvasAvailabilityStatus `json:"availability"` + // Monotonically increasing counter bumped on every change to this + // canvas's state (trust, availability, or incarnation). Clients MAY use it + // to detect and reject stale reads without a full deep comparison. + Revision int64 `json:"revision"` + // Opaque host-defined summary metadata. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// Full state for a single canvas, loaded when a client subscribes to the +// canvas's URI. +// +// `CanvasState` **denormalizes** every {@link CanvasEntry} field directly +// onto itself, replacing `availability`'s lightweight status with the full +// {@link CanvasAvailabilityState} (including declared actions or failure +// detail). Producers MUST keep the two representations consistent: any +// change to the inlined fields SHOULD also be announced on the owning +// session via {@link SessionCanvasSetAction | `session/canvasSet`}. +type CanvasState struct { + // URI of this canvas channel. + Resource URI `json:"resource"` + // Full identity, including current incarnation. + Identity CanvasIdentity `json:"identity"` + // Human-readable display title. + Title string `json:"title"` + // Optional display icon. + Icon *Icon `json:"icon,omitempty"` + // Current trust decision. + Trust CanvasTrustState `json:"trust"` + // Current live resolution state. + Availability CanvasAvailabilityState `json:"availability"` + // Matches {@link CanvasEntry.revision}. + Revision int64 `json:"revision"` + // Opaque host-defined metadata. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// A canvas type an installed extension or package currently makes available +// to open for a chat, as returned by `listCanvasTypes`. +// +// `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is +// unrelated to {@link CanvasEntry}, which represents durable membership of +// an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the +// catalogue (via `listCanvasTypes`) never opens, materializes, or restarts +// anything; only `openCanvas` does. +type CanvasTypeDeclaration struct { + // The extension or package that declares this canvas type. + Source CanvasSource `json:"source"` + // Provider-declared canvas type (host/provider-defined format), passed as + // {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + CanvasType string `json:"canvasType"` + // Human-readable display name for a canvas-type picker. + Title string `json:"title"` + // Description of what this canvas type does. + Description *string `json:"description,omitempty"` + // Optional display icon. + Icon *Icon `json:"icon,omitempty"` + // Inline JSON Schema describing the `openCanvas` `input` this type + // expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + // / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + // `openInputSchemaRef`. + OpenInputSchema *json.RawMessage `json:"openInputSchema,omitempty"` + // Bounded out-of-band reference to a larger open-input JSON Schema, used + // instead of `openInputSchema` when it would exceed + // {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + // inlined. + OpenInputSchemaRef *URI `json:"openInputSchemaRef,omitempty"` + // Advisory, statically-known preview of actions this canvas type + // typically declares once opened (bounded to + // {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + // the actual invocable actions for an opened instance are always + // {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + // depend on live provider configuration) and MUST be used instead of this + // preview once the canvas is open. + DeclaredActions []CanvasActionDeclaration `json:"declaredActions,omitempty"` +} + +// Transient, renderer-neutral presentation of a canvas's current live +// endpoint, returned by `resolveCanvasSource`. +// +// This is a plain URL, not any renderer- or process-model-specific handle +// (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview +// panel reference) — how a client actually presents it (a VS Code Webview, +// the Integrated Browser, or otherwise) is entirely a client/host +// implementation detail outside this protocol. +type CanvasSourcePresentation struct { + // Ephemeral URL to the canvas's current live endpoint. Transient — MUST + // NOT be persisted, cached beyond the current read, or treated as a + // stable/durable identity. A host MAY embed short-lived, single-use + // credentials in it; such credentials are never durable authority. + Url string `json:"url"` + // Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity. + ExpiresAt *string `json:"expiresAt,omitempty"` +} + // ─── Customization Enablement Union ─────────────────────────────────────── // CustomizationEnablement is a single explicit customization enablement decision. @@ -5767,6 +6107,273 @@ func (u AutomationRunLifecycle) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// CanvasSource identifies the explicitly installed extension or package that declares a canvas type. +type CanvasSource struct { + Value isCanvasSource +} + +// isCanvasSource is the marker interface implemented by every +// concrete variant of CanvasSource. +type isCanvasSource interface{ isCanvasSource() } + +func (*CanvasExtensionSource) isCanvasSource() {} +func (*CanvasPackageSource) isCanvasSource() {} + +// CanvasSourceUnknown carries an unrecognized CanvasSource variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. +type CanvasSourceUnknown struct { + Raw json.RawMessage +} + +func (*CanvasSourceUnknown) isCanvasSource() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *CanvasSource) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "extension": + var value CanvasExtensionSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "package": + var value CanvasPackageSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &CanvasSourceUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u CanvasSource) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*CanvasSourceUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + data, err := json.Marshal(u.Value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + switch u.Value.(type) { + case *CanvasExtensionSource: + object["kind"] = json.RawMessage("\"extension\"") + case *CanvasPackageSource: + object["kind"] = json.RawMessage("\"package\"") + } + return json.Marshal(object) +} + +// CanvasTrustState is the current trust decision governing whether a canvas's declared actions may execute. +type CanvasTrustState struct { + Value isCanvasTrustState +} + +// isCanvasTrustState is the marker interface implemented by every +// concrete variant of CanvasTrustState. +type isCanvasTrustState interface{ isCanvasTrustState() } + +func (*CanvasTrustedState) isCanvasTrustState() {} +func (*CanvasPendingTrustState) isCanvasTrustState() {} +func (*CanvasBlockedTrustState) isCanvasTrustState() {} + +// CanvasTrustStateUnknown carries an unrecognized CanvasTrustState variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. +type CanvasTrustStateUnknown struct { + Raw json.RawMessage +} + +func (*CanvasTrustStateUnknown) isCanvasTrustState() {} + +// UnmarshalJSON decodes the variant indicated by the "status" discriminator. +func (u *CanvasTrustState) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "status") + if err != nil { + return err + } + switch disc { + case "trusted": + var value CanvasTrustedState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "pending": + var value CanvasPendingTrustState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "blocked": + var value CanvasBlockedTrustState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &CanvasTrustStateUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u CanvasTrustState) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*CanvasTrustStateUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + data, err := json.Marshal(u.Value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + switch u.Value.(type) { + case *CanvasTrustedState: + object["status"] = json.RawMessage("\"trusted\"") + case *CanvasPendingTrustState: + object["status"] = json.RawMessage("\"pending\"") + case *CanvasBlockedTrustState: + object["status"] = json.RawMessage("\"blocked\"") + } + return json.Marshal(object) +} + +// CanvasAvailabilityState is the current live resolution state of a canvas. +type CanvasAvailabilityState struct { + Value isCanvasAvailabilityState +} + +// isCanvasAvailabilityState is the marker interface implemented by every +// concrete variant of CanvasAvailabilityState. +type isCanvasAvailabilityState interface{ isCanvasAvailabilityState() } + +func (*CanvasUnsupportedAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasNotLoadedAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasLoadingAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasEmptyAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasReadyAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasFailedAvailabilityState) isCanvasAvailabilityState() {} + +// CanvasAvailabilityStateUnknown carries an unrecognized CanvasAvailabilityState variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. +type CanvasAvailabilityStateUnknown struct { + Raw json.RawMessage +} + +func (*CanvasAvailabilityStateUnknown) isCanvasAvailabilityState() {} + +// UnmarshalJSON decodes the variant indicated by the "status" discriminator. +func (u *CanvasAvailabilityState) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "status") + if err != nil { + return err + } + switch disc { + case "unsupported": + var value CanvasUnsupportedAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "notLoaded": + var value CanvasNotLoadedAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "loading": + var value CanvasLoadingAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "empty": + var value CanvasEmptyAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "ready": + var value CanvasReadyAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "failed": + var value CanvasFailedAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &CanvasAvailabilityStateUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u CanvasAvailabilityState) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*CanvasAvailabilityStateUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + data, err := json.Marshal(u.Value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + switch u.Value.(type) { + case *CanvasUnsupportedAvailabilityState: + object["status"] = json.RawMessage("\"unsupported\"") + case *CanvasNotLoadedAvailabilityState: + object["status"] = json.RawMessage("\"notLoaded\"") + case *CanvasLoadingAvailabilityState: + object["status"] = json.RawMessage("\"loading\"") + case *CanvasEmptyAvailabilityState: + object["status"] = json.RawMessage("\"empty\"") + case *CanvasReadyAvailabilityState: + object["status"] = json.RawMessage("\"ready\"") + case *CanvasFailedAvailabilityState: + object["status"] = json.RawMessage("\"failed\"") + } + return json.Marshal(object) +} + // ChatOrigin describes how a chat came into existence. type ChatOrigin struct { Value isChatOrigin diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 456d539c..546b8583 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -6,12 +6,13 @@ package ahptypes // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.9.0" +const ProtocolVersion = "0.10.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 4faee915..3f1b9a89 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,6 +2,7 @@ "client": "go", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index bdf2a488..63e46946 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,6 +2,7 @@ "client": "kotlin", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index e0326f28..a84b3932 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -125,6 +125,12 @@ value class ActionType(val rawValue: String) { val AUTOMATION_RUN_SESSION_REMOVED: ActionType = ActionType("automationRun/sessionRemoved") val AUTOMATION_RUN_PRIMARY_SESSION_CHANGED: ActionType = ActionType("automationRun/primarySessionChanged") val AUTOMATION_RUN_CANCEL_REQUESTED: ActionType = ActionType("automationRun/cancelRequested") + val SESSION_CANVAS_SET: ActionType = ActionType("session/canvasSet") + val SESSION_CANVAS_REMOVED: ActionType = ActionType("session/canvasRemoved") + val CANVAS_AVAILABILITY_CHANGED: ActionType = ActionType("canvas/availabilityChanged") + val CANVAS_TRUST_CHANGED: ActionType = ActionType("canvas/trustChanged") + val CANVAS_INCARNATION_CHANGED: ActionType = ActionType("canvas/incarnationChanged") + val CANVAS_TITLE_CHANGED: ActionType = ActionType("canvas/titleChanged") } } @@ -1522,6 +1528,80 @@ data class AutomationRunCancelRequestedAction( val type: ActionType ) +@Serializable +data class SessionCanvasSetAction( + val type: ActionType, + /** + * The canvas entry to add or update, matched by `resource`. + */ + val canvas: CanvasEntry +) + +@Serializable +data class SessionCanvasRemovedAction( + val type: ActionType, + /** + * Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + */ + val resource: String +) + +@Serializable +data class CanvasAvailabilityChangedAction( + val type: ActionType, + /** + * New {@link CanvasState.availability}. + */ + val availability: CanvasAvailabilityState, + /** + * The {@link CanvasState.revision} this action results in. The reducer + * MUST reject (no-op) this action if `revision` is not strictly greater + * than the canvas's current `revision` — this is how stale/out-of-order + * deliveries are consistently rejected across every canvas action, not + * just this one. + */ + val revision: Long +) + +@Serializable +data class CanvasTrustChangedAction( + val type: ActionType, + /** + * New {@link CanvasState.trust}. + */ + val trust: CanvasTrustState, + /** + * The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + */ + val revision: Long +) + +@Serializable +data class CanvasIncarnationChangedAction( + val type: ActionType, + /** + * New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + */ + val incarnation: String, + /** + * The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + */ + val revision: Long +) + +@Serializable +data class CanvasTitleChangedAction( + val type: ActionType, + /** + * New {@link CanvasState.title}. + */ + val title: String, + /** + * The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + */ + val revision: Long +) + // ─── Partial Summary Types ────────────────────────────────────────────────── @Serializable @@ -1675,6 +1755,12 @@ sealed interface StateAction @JvmInline value class StateActionAutomationRunSessionRemoved(val value: AutomationRunSessionRemovedAction) : StateAction @JvmInline value class StateActionAutomationRunPrimarySessionChanged(val value: AutomationRunPrimarySessionChangedAction) : StateAction @JvmInline value class StateActionAutomationRunCancelRequested(val value: AutomationRunCancelRequestedAction) : StateAction +@JvmInline value class StateActionSessionCanvasSet(val value: SessionCanvasSetAction) : StateAction +@JvmInline value class StateActionSessionCanvasRemoved(val value: SessionCanvasRemovedAction) : StateAction +@JvmInline value class StateActionCanvasAvailabilityChanged(val value: CanvasAvailabilityChangedAction) : StateAction +@JvmInline value class StateActionCanvasTrustChanged(val value: CanvasTrustChangedAction) : StateAction +@JvmInline value class StateActionCanvasIncarnationChanged(val value: CanvasIncarnationChangedAction) : StateAction +@JvmInline value class StateActionCanvasTitleChanged(val value: CanvasTitleChangedAction) : StateAction @JvmInline value class StateActionUnknown(val raw: JsonObject) : StateAction internal object StateActionSerializer : KSerializer { @@ -1786,6 +1872,12 @@ internal object StateActionSerializer : KSerializer { "automationRun/sessionRemoved" -> StateActionAutomationRunSessionRemoved(input.json.decodeFromJsonElement(AutomationRunSessionRemovedAction.serializer(), element)) "automationRun/primarySessionChanged" -> StateActionAutomationRunPrimarySessionChanged(input.json.decodeFromJsonElement(AutomationRunPrimarySessionChangedAction.serializer(), element)) "automationRun/cancelRequested" -> StateActionAutomationRunCancelRequested(input.json.decodeFromJsonElement(AutomationRunCancelRequestedAction.serializer(), element)) + "session/canvasSet" -> StateActionSessionCanvasSet(input.json.decodeFromJsonElement(SessionCanvasSetAction.serializer(), element)) + "session/canvasRemoved" -> StateActionSessionCanvasRemoved(input.json.decodeFromJsonElement(SessionCanvasRemovedAction.serializer(), element)) + "canvas/availabilityChanged" -> StateActionCanvasAvailabilityChanged(input.json.decodeFromJsonElement(CanvasAvailabilityChangedAction.serializer(), element)) + "canvas/trustChanged" -> StateActionCanvasTrustChanged(input.json.decodeFromJsonElement(CanvasTrustChangedAction.serializer(), element)) + "canvas/incarnationChanged" -> StateActionCanvasIncarnationChanged(input.json.decodeFromJsonElement(CanvasIncarnationChangedAction.serializer(), element)) + "canvas/titleChanged" -> StateActionCanvasTitleChanged(input.json.decodeFromJsonElement(CanvasTitleChangedAction.serializer(), element)) else -> StateActionUnknown(obj) } } @@ -1890,6 +1982,12 @@ internal object StateActionSerializer : KSerializer { is StateActionAutomationRunSessionRemoved -> output.json.encodeToJsonElement(AutomationRunSessionRemovedAction.serializer(), value.value) is StateActionAutomationRunPrimarySessionChanged -> output.json.encodeToJsonElement(AutomationRunPrimarySessionChangedAction.serializer(), value.value) is StateActionAutomationRunCancelRequested -> output.json.encodeToJsonElement(AutomationRunCancelRequestedAction.serializer(), value.value) + is StateActionSessionCanvasSet -> output.json.encodeToJsonElement(SessionCanvasSetAction.serializer(), value.value) + is StateActionSessionCanvasRemoved -> output.json.encodeToJsonElement(SessionCanvasRemovedAction.serializer(), value.value) + is StateActionCanvasAvailabilityChanged -> output.json.encodeToJsonElement(CanvasAvailabilityChangedAction.serializer(), value.value) + is StateActionCanvasTrustChanged -> output.json.encodeToJsonElement(CanvasTrustChangedAction.serializer(), value.value) + is StateActionCanvasIncarnationChanged -> output.json.encodeToJsonElement(CanvasIncarnationChangedAction.serializer(), value.value) + is StateActionCanvasTitleChanged -> output.json.encodeToJsonElement(CanvasTitleChangedAction.serializer(), value.value) is StateActionUnknown -> value.raw } output.encodeJsonElement(element) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 01fb0e15..4ef0b05e 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -390,7 +390,25 @@ data class InitializeResult( * `ahp-automations://` for {@link AutomationState}; absence means the * host does not expose an automation catalogue or automation commands. */ - val automations: AutomationCapabilities? = null + val automations: AutomationCapabilities? = null, + /** + * Host/runtime-owned local-canvas support. Presence means the SERVER + * currently has a working runtime able to serve `openCanvas` / + * `invokeCanvasAction` for at least one qualifying (explicitly installed + * and trust-eligible) extension/package source; absence means the host + * has no available canvas runtime, and clients MUST treat every canvas as + * {@link CanvasAvailabilityStatus.Unsupported} regardless of what + * {@link ClientCapabilities.canvases} declared. + * + * **Protocol version support alone is not a runtime capability**: a host + * speaking protocol `>= 0.10.0` without this field present MUST NOT be + * assumed to have a usable canvas runtime. This field — not the + * negotiated `protocolVersion` — is the authoritative signal, and is + * independent of any individual canvas's live availability + * ({@link CanvasAvailabilityState}) or trust decision + * ({@link CanvasTrustState}). + */ + val canvases: CanvasCapabilities? = null ) @Serializable @@ -408,7 +426,26 @@ data class ClientCapabilities( * capability is declared. Clients that omit it MUST treat * App-bearing tool calls as ordinary MCP tool calls. */ - val mcpApps: Map? = null + val mcpApps: Map? = null, + /** + * Client can render local canvases: `listCanvasTypes`, `openCanvas`, + * subscribe to the resulting `ahp-canvas:` channel, and drive + * `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + * `closeCanvas`. + * + * Hosts SHOULD NOT offer canvas admission to a client that omits this + * capability; such a client MUST be treated as if every canvas were + * {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + * anything about server/runtime execution trust — see + * {@link CanvasTrustStatus}, which is a separate, host-owned decision. + * + * This declares only the CLIENT's rendering capability. Protocol version + * support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + * actually has a working canvas runtime — see + * {@link InitializeResult.canvases}, the server-side counterpart, which a + * client MUST also check before treating canvases as usable. + */ + val canvases: Map? = null ) @Serializable @@ -434,6 +471,9 @@ data class AutomationCapabilities( val runHistoryLimit: Long? = null ) +@Serializable +class CanvasCapabilities + @Serializable class AutomationCreateCapability @@ -1674,6 +1714,226 @@ data class FetchAutomationRunsParams( @Serializable class FetchAutomationRunsResult +@Serializable +data class ListCanvasTypesParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Maximum number of entries to return in this page. The server SHOULD respect + * this bound but MAY return fewer entries and MAY impose its own upper cap. + * Omit to let the server choose the page size. + */ + val limit: Long? = null, + /** + * Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + * Omit to fetch the first page. Cursors are server-defined and MUST be treated + * as opaque — do not parse, modify, or persist them across connections. An + * unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + */ + val cursor: String? = null +) + +@Serializable +data class ListCanvasTypesResult( + /** + * Opaque cursor for the next page. Present when more entries exist beyond the + * returned page; absent signals the end of the collection. Pass it back as + * {@link PaginatedParams.cursor} to fetch the following page. + */ + val nextCursor: String? = null, + /** + * Discovered canvas type declarations. + */ + val types: List +) + +@Serializable +data class OpenCanvasParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + */ + val canvas: String, + /** + * Logical identity to open or re-admit. + */ + val identity: CanvasIdentityKey, + /** + * Initial (or updated, on a later effectful call) display title. + */ + val title: String, + /** + * Initial (or updated) display icon. + */ + val icon: Icon? = null, + /** + * Bounded JSON input for this open call (e.g. seed parameters the + * provider uses to initialize the canvas), opaque to the protocol. See + * {@link CanvasTypeDeclaration.openInputSchema} / + * `openInputSchemaRef` for the expected shape. The JSON-serialized value + * MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + */ + val input: JsonElement? = null, + /** + * Durable client-generated idempotency key bounding retry deduplication + * for this call within a live window; see the idempotency rules above. + * MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String +) + +@Serializable +data class OpenCanvasResult( + /** + * The catalog entry for the opened (or already-open) canvas. + */ + val canvas: CanvasEntry +) + +@Serializable +data class ResolveCanvasSourceParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class ResolveCanvasSourceResult( + /** + * Current {@link CanvasEntry.availability}. + */ + val availability: CanvasAvailabilityStatus, + /** + * Current {@link CanvasIdentity.incarnation}. + */ + val incarnation: String, + /** + * Current {@link CanvasEntry.revision}. + */ + val revision: Long, + /** + * Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + */ + val source: CanvasSourcePresentation? = null +) + +@Serializable +data class InvokeCanvasActionParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + */ + val actionId: String, + /** + * Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + * if any. The JSON-serialized value MUST NOT exceed + * `CANVAS_INPUT_MAX_LENGTH`. + */ + val input: JsonElement? = null, + /** + * Expected {@link CanvasIdentity.incarnation}. Required — see above. The + * server MUST reject the call with `Conflict` if the canvas's live + * endpoint has since been superseded, rather than deliver the call to it. + */ + val incarnation: String, + /** + * Durable client-generated idempotency key bounding retry + * deduplication for this invocation within a live window. The server is + * not required to guarantee exactly-once execution across a crash. MUST + * NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String +) + +@Serializable +data class InvokeCanvasActionResult( + /** + * The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + */ + val result: JsonElement +) + +@Serializable +data class RestartCanvasProviderParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String, + /** + * Expected current {@link CanvasIdentity.incarnation}; required — see above. + */ + val incarnation: String +) + +@Serializable +data class CloseCanvasParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String, + /** + * Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + */ + val revision: Long +) + // ─── ChatSource Union ─────────────────────────────────────────────────────── @Serializable(with = ChatSourceSerializer::class) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index 9fee270b..c33e14e8 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -1141,6 +1141,126 @@ enum class AutomationRunOriginKind { TRIGGER } +/** + * Discriminant for {@link CanvasSource} — what kind of package originates a + * canvas type. + */ +@Serializable(with = CanvasSourceKindSerializer::class) +@JvmInline +value class CanvasSourceKind(val rawValue: String) { + companion object { + /** + * An explicitly installed host extension. + */ + val EXTENSION: CanvasSourceKind = CanvasSourceKind("extension") + /** + * An explicitly installed package (not a host extension). + */ + val PACKAGE: CanvasSourceKind = CanvasSourceKind("package") + } +} + +internal object CanvasSourceKindSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CanvasSourceKind", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: CanvasSourceKind) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): CanvasSourceKind = + CanvasSourceKind(decoder.decodeString()) +} + +/** + * Discriminant for {@link CanvasTrustState} — whether the host currently + * permits this canvas's declared actions to execute. + * + * Trust is independent of {@link CanvasAvailabilityStatus | availability}: + * a canvas may be perfectly capable of rendering while blocked from + * executing actions, and vice versa. Trust decisions are host/runtime + * authority, not something this protocol grants. + */ +@Serializable(with = CanvasTrustStatusSerializer::class) +@JvmInline +value class CanvasTrustStatus(val rawValue: String) { + companion object { + /** + * Declared actions may be invoked. + */ + val TRUSTED: CanvasTrustStatus = CanvasTrustStatus("trusted") + /** + * A trust decision has not yet been made (e.g. first use of a new/changed source). + */ + val PENDING: CanvasTrustStatus = CanvasTrustStatus("pending") + /** + * The host has denied execution; declared actions MUST NOT be invoked. + */ + val BLOCKED: CanvasTrustStatus = CanvasTrustStatus("blocked") + } +} + +internal object CanvasTrustStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CanvasTrustStatus", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: CanvasTrustStatus) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): CanvasTrustStatus = + CanvasTrustStatus(decoder.decodeString()) +} + +/** + * Discriminant for {@link CanvasAvailabilityState} — the canvas's current + * live resolution state, independent of its durable + * {@link CanvasEntry | membership} in a session's catalog. + * + * An empty catalog membership list is not itself a close, and a canvas may + * remain a recorded member while its live availability cycles through these + * states any number of times (e.g. across provider restarts). + */ +@Serializable(with = CanvasAvailabilityStatusSerializer::class) +@JvmInline +value class CanvasAvailabilityStatus(val rawValue: String) { + companion object { + /** + * The connected client or host does not support this canvas type (e.g. + * the client omitted the `canvases` capability, or no local runtime can + * render this `canvasType`). Distinct from `blocked` trust, which is a + * policy decision rather than a capability gap. + */ + val UNSUPPORTED: CanvasAvailabilityStatus = CanvasAvailabilityStatus("unsupported") + /** + * Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + */ + val NOT_LOADED: CanvasAvailabilityStatus = CanvasAvailabilityStatus("notLoaded") + /** + * Currently resolving or (re)connecting to a live endpoint. + */ + val LOADING: CanvasAvailabilityStatus = CanvasAvailabilityStatus("loading") + /** + * Live and reachable, but the provider has not yet produced content to render. + */ + val EMPTY: CanvasAvailabilityStatus = CanvasAvailabilityStatus("empty") + /** + * Live, reachable, and has declared its current actions. + */ + val READY: CanvasAvailabilityStatus = CanvasAvailabilityStatus("ready") + /** + * The live endpoint failed to resolve, or resolution otherwise failed. + */ + val FAILED: CanvasAvailabilityStatus = CanvasAvailabilityStatus("failed") + } +} + +internal object CanvasAvailabilityStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CanvasAvailabilityStatus", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: CanvasAvailabilityStatus) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): CanvasAvailabilityStatus = + CanvasAvailabilityStatus(decoder.decodeString()) +} + // ─── State Types ──────────────────────────────────────────────────────────── @Serializable @@ -1835,6 +1955,15 @@ data class SessionState( * {@link /guide/changesets | Changesets} for an overview of the model. */ val changesets: List? = null, + /** + * Catalog of canvases opened for chats in this session. Presence is + * durable logical membership, admitted only via `openCanvas` — never + * implied by a chat's existence or a client's earlier focus. Each entry's + * {@link CanvasIdentity.chat | `identity.chat`} identifies the exact + * backing chat; a canvas never migrates to a different chat. See + * {@link CanvasEntry} for the full membership/availability/trust model. + */ + val canvases: List? = null, /** * Outstanding input the session is blocked on, aggregated across every chat * so a client can discover and answer it from the session channel alone, @@ -5604,6 +5733,343 @@ data class AutomationRunState( val meta: Map? = null ) +@Serializable +data class CanvasExtensionSource( + val kind: CanvasSourceKind, + /** + * Stable extension identifier (host-defined format, e.g. `publisher.name`). + * MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val extensionId: String, + /** + * Installed extension version, when known. Metadata only — not identity-bearing. + */ + val version: String? = null +) + +@Serializable +data class CanvasPackageSource( + val kind: CanvasSourceKind, + /** + * Stable, host- or package-manager-assigned unique identifier for this + * specific installed package instance/scope (opaque format). This is the + * identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val sourceId: String, + /** + * Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + */ + val packageName: String, + /** + * Installed package version, when known. Metadata only — not identity-bearing. + */ + val version: String? = null +) + +@Serializable +data class CanvasIdentityKey( + /** + * The exact backing chat this canvas belongs to. A canvas is never + * re-associated with a different chat; opening a new one for another chat + * creates a distinct canvas. + */ + val chat: String, + /** + * The extension or package that declares this canvas's type. + */ + val source: CanvasSource, + /** + * Provider-declared canvas type (host/provider-defined format). MUST NOT + * exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val canvasType: String, + /** + * Provider-chosen stable identifier for this canvas instance, scoped to + * `(chat, source, canvasType)`. Stable across reloads and host/window + * restarts for the same logical canvas. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val instanceId: String +) + +@Serializable +data class CanvasIdentity( + /** + * The exact backing chat this canvas belongs to. A canvas is never + * re-associated with a different chat; opening a new one for another chat + * creates a distinct canvas. + */ + val chat: String, + /** + * The extension or package that declares this canvas's type. + */ + val source: CanvasSource, + /** + * Provider-declared canvas type (host/provider-defined format). MUST NOT + * exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val canvasType: String, + /** + * Provider-chosen stable identifier for this canvas instance, scoped to + * `(chat, source, canvasType)`. Stable across reloads and host/window + * restarts for the same logical canvas. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val instanceId: String, + /** + * Opaque, host-generated token identifying the current generation of this + * canvas's live endpoint. The host mints a fresh token whenever a provider + * restart retires the previous live endpoint and establishes a new one for + * the same logical instance (see {@link CanvasIncarnationChangedAction | + * `canvas/incarnationChanged`}); it is not changed by a plain page reload + * against the same still-live endpoint. + * + * `incarnation` is **opaque**: clients and hosts MUST compare it only for + * equality, never parse it, sort it, or perform arithmetic on it (e.g. it + * is not guaranteed to be numeric or monotonically increasing). The host + * MUST NOT reuse a token for this logical identity once it has been + * superseded, including across a host/process restart — if the host + * cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + * or timestamp-derived) that make accidental reuse practically + * impossible, rather than a small resettable counter. + * + * Clients and hosts use `incarnation` to reject stale callbacks and + * in-flight effects addressed to a superseded endpoint. + */ + val incarnation: String +) + +@Serializable +data class CanvasTrustedState( + val status: CanvasTrustStatus +) + +@Serializable +data class CanvasPendingTrustState( + val status: CanvasTrustStatus +) + +@Serializable +data class CanvasBlockedTrustState( + val status: CanvasTrustStatus, + /** + * Optional human-readable reason surfaced to the user. + */ + val reason: String? = null +) + +@Serializable +data class CanvasActionDeclaration( + /** + * Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + */ + val id: String, + /** + * Human-readable display name. + */ + val title: String? = null, + /** + * Description of what invoking the action does. + */ + val description: String? = null, + /** + * Inline JSON Schema for the expected `input`, when small enough to embed + * (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + * checked by {@link isCanvasSchemaWithinLimits}). Optional because some + * declared actions take no input. Mutually exclusive with + * `inputSchemaRef` — a declaration MUST supply at most one of the two. + */ + val inputSchema: JsonElement? = null, + /** + * Bounded out-of-band reference to a larger JSON Schema, used instead of + * `inputSchema` when the schema would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. AHP does not mandate a specific resolution mechanism for this + * URI (e.g. a host MAY make it `resourceRead`-able). + */ + val inputSchemaRef: String? = null +) + +@Serializable +data class CanvasUnsupportedAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasNotLoadedAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasLoadingAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasEmptyAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasReadyAvailabilityState( + val status: CanvasAvailabilityStatus, + /** + * Actions currently declared by the live provider (full replacement each time this state is produced). + */ + val actions: List +) + +@Serializable +data class CanvasFailedAvailabilityState( + val status: CanvasAvailabilityStatus, + /** + * Stable machine-readable and human-readable failure information. + */ + val error: ErrorInfo +) + +@Serializable +data class CanvasEntry( + /** + * Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + */ + val resource: String, + /** + * Full identity, including current incarnation. + */ + val identity: CanvasIdentity, + /** + * Human-readable display title. + */ + val title: String, + /** + * Optional display icon. + */ + val icon: Icon? = null, + /** + * Current trust decision matching {@link CanvasState.trust}. + */ + val trust: CanvasTrustState, + /** + * Current availability status matching {@link CanvasState.availability}'s discriminant. + */ + val availability: CanvasAvailabilityStatus, + /** + * Monotonically increasing counter bumped on every change to this + * canvas's state (trust, availability, or incarnation). Clients MAY use it + * to detect and reject stale reads without a full deep comparison. + */ + val revision: Long, + /** + * Opaque host-defined summary metadata. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class CanvasState( + /** + * URI of this canvas channel. + */ + val resource: String, + /** + * Full identity, including current incarnation. + */ + val identity: CanvasIdentity, + /** + * Human-readable display title. + */ + val title: String, + /** + * Optional display icon. + */ + val icon: Icon? = null, + /** + * Current trust decision. + */ + val trust: CanvasTrustState, + /** + * Current live resolution state. + */ + val availability: CanvasAvailabilityState, + /** + * Matches {@link CanvasEntry.revision}. + */ + val revision: Long, + /** + * Opaque host-defined metadata. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class CanvasTypeDeclaration( + /** + * The extension or package that declares this canvas type. + */ + val source: CanvasSource, + /** + * Provider-declared canvas type (host/provider-defined format), passed as + * {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val canvasType: String, + /** + * Human-readable display name for a canvas-type picker. + */ + val title: String, + /** + * Description of what this canvas type does. + */ + val description: String? = null, + /** + * Optional display icon. + */ + val icon: Icon? = null, + /** + * Inline JSON Schema describing the `openCanvas` `input` this type + * expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + * / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + * `openInputSchemaRef`. + */ + val openInputSchema: JsonElement? = null, + /** + * Bounded out-of-band reference to a larger open-input JSON Schema, used + * instead of `openInputSchema` when it would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. + */ + val openInputSchemaRef: String? = null, + /** + * Advisory, statically-known preview of actions this canvas type + * typically declares once opened (bounded to + * {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + * the actual invocable actions for an opened instance are always + * {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + * depend on live provider configuration) and MUST be used instead of this + * preview once the canvas is open. + */ + val declaredActions: List? = null +) + +@Serializable +data class CanvasSourcePresentation( + /** + * Ephemeral URL to the canvas's current live endpoint. Transient — MUST + * NOT be persisted, cached beyond the current read, or treated as a + * stable/durable identity. A host MAY embed short-lived, single-use + * credentials in it; such credentials are never durable authority. + */ + val url: String, + /** + * Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity. + */ + val expiresAt: String? = null +) + // ─── Customization Enablement Union ───────────────────────────────────── /** @@ -6895,6 +7361,199 @@ internal object AutomationRunLifecycleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CanvasSource") + + override fun deserialize(decoder: Decoder): CanvasSource { + val input = decoder as? JsonDecoder + ?: error("CanvasSource can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CanvasSource") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: return CanvasSourceUnknown(obj) + return when (discriminant) { + "extension" -> CanvasSourceExtension(input.json.decodeFromJsonElement(CanvasExtensionSource.serializer(), element)) + "package" -> CanvasSourcePackage(input.json.decodeFromJsonElement(CanvasPackageSource.serializer(), element)) + else -> CanvasSourceUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: CanvasSource) { + val output = encoder as? JsonEncoder + ?: error("CanvasSource can only be serialized to JSON") + val element: JsonElement = when (value) { + is CanvasSourceExtension -> output.json.encodeToJsonElement(CanvasExtensionSource.serializer(), value.value) + is CanvasSourcePackage -> output.json.encodeToJsonElement(CanvasPackageSource.serializer(), value.value) + is CanvasSourceUnknown -> value.raw + } + val encodedObject = element.jsonObject.toMutableMap() + val discriminant = when (value) { + is CanvasSourceExtension -> "extension" + is CanvasSourcePackage -> "package" + is CanvasSourceUnknown -> null + } + if (discriminant != null) encodedObject["kind"] = JsonPrimitive(discriminant) + output.encodeJsonElement(JsonObject(encodedObject)) + } +} + +@Serializable(with = CanvasTrustStateSerializer::class) +sealed interface CanvasTrustState + +@JvmInline +value class CanvasTrustStateTrusted(val value: CanvasTrustedState) : CanvasTrustState +@JvmInline +value class CanvasTrustStatePending(val value: CanvasPendingTrustState) : CanvasTrustState +@JvmInline +value class CanvasTrustStateBlocked(val value: CanvasBlockedTrustState) : CanvasTrustState +/** + * Forward-compat catch-all for unknown CanvasTrustState discriminators. + * + * Older clients may receive newer wire variants they don't recognise; capturing + * the raw `JsonObject` lets such payloads round-trip through the client unchanged. + * Reducers handle this variant conservatively on a per-union basis (typically + * as a no-op, but see `Reducers.kt` for the exact treatment). + */ +@JvmInline +value class CanvasTrustStateUnknown(val raw: JsonObject) : CanvasTrustState + +internal object CanvasTrustStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CanvasTrustState") + + override fun deserialize(decoder: Decoder): CanvasTrustState { + val input = decoder as? JsonDecoder + ?: error("CanvasTrustState can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CanvasTrustState") + val discriminant = (obj["status"] as? JsonPrimitive)?.content + ?: return CanvasTrustStateUnknown(obj) + return when (discriminant) { + "trusted" -> CanvasTrustStateTrusted(input.json.decodeFromJsonElement(CanvasTrustedState.serializer(), element)) + "pending" -> CanvasTrustStatePending(input.json.decodeFromJsonElement(CanvasPendingTrustState.serializer(), element)) + "blocked" -> CanvasTrustStateBlocked(input.json.decodeFromJsonElement(CanvasBlockedTrustState.serializer(), element)) + else -> CanvasTrustStateUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: CanvasTrustState) { + val output = encoder as? JsonEncoder + ?: error("CanvasTrustState can only be serialized to JSON") + val element: JsonElement = when (value) { + is CanvasTrustStateTrusted -> output.json.encodeToJsonElement(CanvasTrustedState.serializer(), value.value) + is CanvasTrustStatePending -> output.json.encodeToJsonElement(CanvasPendingTrustState.serializer(), value.value) + is CanvasTrustStateBlocked -> output.json.encodeToJsonElement(CanvasBlockedTrustState.serializer(), value.value) + is CanvasTrustStateUnknown -> value.raw + } + val encodedObject = element.jsonObject.toMutableMap() + val discriminant = when (value) { + is CanvasTrustStateTrusted -> "trusted" + is CanvasTrustStatePending -> "pending" + is CanvasTrustStateBlocked -> "blocked" + is CanvasTrustStateUnknown -> null + } + if (discriminant != null) encodedObject["status"] = JsonPrimitive(discriminant) + output.encodeJsonElement(JsonObject(encodedObject)) + } +} + +@Serializable(with = CanvasAvailabilityStateSerializer::class) +sealed interface CanvasAvailabilityState + +@JvmInline +value class CanvasAvailabilityStateUnsupported(val value: CanvasUnsupportedAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateNotLoaded(val value: CanvasNotLoadedAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateLoading(val value: CanvasLoadingAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateEmpty(val value: CanvasEmptyAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateReady(val value: CanvasReadyAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateFailed(val value: CanvasFailedAvailabilityState) : CanvasAvailabilityState +/** + * Forward-compat catch-all for unknown CanvasAvailabilityState discriminators. + * + * Older clients may receive newer wire variants they don't recognise; capturing + * the raw `JsonObject` lets such payloads round-trip through the client unchanged. + * Reducers handle this variant conservatively on a per-union basis (typically + * as a no-op, but see `Reducers.kt` for the exact treatment). + */ +@JvmInline +value class CanvasAvailabilityStateUnknown(val raw: JsonObject) : CanvasAvailabilityState + +internal object CanvasAvailabilityStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CanvasAvailabilityState") + + override fun deserialize(decoder: Decoder): CanvasAvailabilityState { + val input = decoder as? JsonDecoder + ?: error("CanvasAvailabilityState can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CanvasAvailabilityState") + val discriminant = (obj["status"] as? JsonPrimitive)?.content + ?: return CanvasAvailabilityStateUnknown(obj) + return when (discriminant) { + "unsupported" -> CanvasAvailabilityStateUnsupported(input.json.decodeFromJsonElement(CanvasUnsupportedAvailabilityState.serializer(), element)) + "notLoaded" -> CanvasAvailabilityStateNotLoaded(input.json.decodeFromJsonElement(CanvasNotLoadedAvailabilityState.serializer(), element)) + "loading" -> CanvasAvailabilityStateLoading(input.json.decodeFromJsonElement(CanvasLoadingAvailabilityState.serializer(), element)) + "empty" -> CanvasAvailabilityStateEmpty(input.json.decodeFromJsonElement(CanvasEmptyAvailabilityState.serializer(), element)) + "ready" -> CanvasAvailabilityStateReady(input.json.decodeFromJsonElement(CanvasReadyAvailabilityState.serializer(), element)) + "failed" -> CanvasAvailabilityStateFailed(input.json.decodeFromJsonElement(CanvasFailedAvailabilityState.serializer(), element)) + else -> CanvasAvailabilityStateUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: CanvasAvailabilityState) { + val output = encoder as? JsonEncoder + ?: error("CanvasAvailabilityState can only be serialized to JSON") + val element: JsonElement = when (value) { + is CanvasAvailabilityStateUnsupported -> output.json.encodeToJsonElement(CanvasUnsupportedAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateNotLoaded -> output.json.encodeToJsonElement(CanvasNotLoadedAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateLoading -> output.json.encodeToJsonElement(CanvasLoadingAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateEmpty -> output.json.encodeToJsonElement(CanvasEmptyAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateReady -> output.json.encodeToJsonElement(CanvasReadyAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateFailed -> output.json.encodeToJsonElement(CanvasFailedAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateUnknown -> value.raw + } + val encodedObject = element.jsonObject.toMutableMap() + val discriminant = when (value) { + is CanvasAvailabilityStateUnsupported -> "unsupported" + is CanvasAvailabilityStateNotLoaded -> "notLoaded" + is CanvasAvailabilityStateLoading -> "loading" + is CanvasAvailabilityStateEmpty -> "empty" + is CanvasAvailabilityStateReady -> "ready" + is CanvasAvailabilityStateFailed -> "failed" + is CanvasAvailabilityStateUnknown -> null + } + if (discriminant != null) encodedObject["status"] = JsonPrimitive(discriminant) + output.encodeJsonElement(JsonObject(encodedObject)) + } +} + @Serializable(with = ToolResultContentSerializer::class) sealed interface ToolResultContent { @JvmInline value class Text(val value: ToolResultTextContent) : ToolResultContent diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index 31c2b348..d4da3055 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.9.0" +public const val PROTOCOL_VERSION: String = "0.10.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,6 +16,7 @@ public const val PROTOCOL_VERSION: String = "0.9.0" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 05dba2c2..bae2a3af 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -126,6 +126,12 @@ pub enum ActionType { AutomationRunSessionRemoved, AutomationRunPrimarySessionChanged, AutomationRunCancelRequested, + SessionCanvasSet, + SessionCanvasRemoved, + CanvasAvailabilityChanged, + CanvasTrustChanged, + CanvasIncarnationChanged, + CanvasTitleChanged, /// Unknown raw value from a newer protocol version, preserved verbatim. Unknown(String), } @@ -292,6 +298,14 @@ impl serde::Serialize for ActionType { Self::AutomationRunCancelRequested => { serializer.serialize_str("automationRun/cancelRequested") } + Self::SessionCanvasSet => serializer.serialize_str("session/canvasSet"), + Self::SessionCanvasRemoved => serializer.serialize_str("session/canvasRemoved"), + Self::CanvasAvailabilityChanged => { + serializer.serialize_str("canvas/availabilityChanged") + } + Self::CanvasTrustChanged => serializer.serialize_str("canvas/trustChanged"), + Self::CanvasIncarnationChanged => serializer.serialize_str("canvas/incarnationChanged"), + Self::CanvasTitleChanged => serializer.serialize_str("canvas/titleChanged"), Self::Unknown(value) => serializer.serialize_str(value), } } @@ -400,6 +414,12 @@ impl<'de> serde::Deserialize<'de> for ActionType { "automationRun/sessionRemoved" => Self::AutomationRunSessionRemoved, "automationRun/primarySessionChanged" => Self::AutomationRunPrimarySessionChanged, "automationRun/cancelRequested" => Self::AutomationRunCancelRequested, + "session/canvasSet" => Self::SessionCanvasSet, + "session/canvasRemoved" => Self::SessionCanvasRemoved, + "canvas/availabilityChanged" => Self::CanvasAvailabilityChanged, + "canvas/trustChanged" => Self::CanvasTrustChanged, + "canvas/incarnationChanged" => Self::CanvasIncarnationChanged, + "canvas/titleChanged" => Self::CanvasTitleChanged, _ => Self::Unknown(raw), }) } @@ -2146,6 +2166,96 @@ pub struct AutomationRunPrimarySessionChangedAction { #[serde(rename_all = "camelCase")] pub struct AutomationRunCancelRequestedAction {} +/// A canvas was admitted (opened) or its catalog entry changed. +/// +/// Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the +/// server dispatches this with the full entry to record a newly opened +/// canvas, or to republish it after a trust/availability/incarnation change +/// so subscribers following only the session channel stay in sync with +/// {@link CanvasState}. Never client-dispatchable — canvases are admitted +/// only through the `openCanvas` command. A stale/out-of-order delivery +/// (`canvas.revision` not strictly greater than the currently-recorded +/// entry's revision) MUST be rejected (no-op) rather than overwrite a newer +/// entry with older data. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCanvasSetAction { + /// The canvas entry to add or update, matched by `resource`. + pub canvas: CanvasEntry, +} + +/// A canvas was logically closed. +/// +/// Remove semantics keyed by `resource`: an unknown URI is a no-op. This +/// represents durable membership removal, not a client hiding a local +/// tab/view — see `closeCanvas`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCanvasRemovedAction { + /// Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + pub resource: Uri, +} + +/// Replaces the canvas's live resolution state. +/// +/// Dispatched by the host on every availability transition: initial +/// resolution after `openCanvas`, provider restart, reload, and failure. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasAvailabilityChangedAction { + /// New {@link CanvasState.availability}. + pub availability: CanvasAvailabilityState, + /// The {@link CanvasState.revision} this action results in. The reducer + /// MUST reject (no-op) this action if `revision` is not strictly greater + /// than the canvas's current `revision` — this is how stale/out-of-order + /// deliveries are consistently rejected across every canvas action, not + /// just this one. + pub revision: i64, +} + +/// Replaces the canvas's trust decision. +/// +/// Dispatched by the host whenever the execution-trust decision for this +/// canvas's declared actions changes (e.g. a pending decision resolves, or an +/// administrator revokes a previously trusted source). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTrustChangedAction { + /// New {@link CanvasState.trust}. + pub trust: CanvasTrustState, + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + pub revision: i64, +} + +/// Records that the canvas's live endpoint was replaced by a fresh one for +/// the same logical instance (e.g. the owning provider restarted). +/// +/// The host MUST dispatch {@link CanvasAvailabilityChangedAction} to +/// transition through `notLoaded`/`loading` around this change. Receivers +/// MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed +/// callbacks addressed to a superseded `incarnation` — because `incarnation` +/// is opaque (see {@link CanvasIdentity.incarnation}), that rejection is +/// driven by the accompanying `revision` bump here, not by comparing +/// `incarnation` values for order. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasIncarnationChangedAction { + /// New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + pub incarnation: String, + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + pub revision: i64, +} + +/// Replaces the canvas's display title. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTitleChangedAction { + /// New {@link CanvasState.title}. + pub title: String, + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + pub revision: i64, +} + // ─── Partial Summaries ──────────────────────────────────────────────── /// Partial equivalent of ChatSummary — every field is optional for delta updates. @@ -2381,6 +2491,18 @@ pub enum StateAction { AutomationRunPrimarySessionChanged(AutomationRunPrimarySessionChangedAction), #[serde(rename = "automationRun/cancelRequested")] AutomationRunCancelRequested(AutomationRunCancelRequestedAction), + #[serde(rename = "session/canvasSet")] + SessionCanvasSet(SessionCanvasSetAction), + #[serde(rename = "session/canvasRemoved")] + SessionCanvasRemoved(SessionCanvasRemovedAction), + #[serde(rename = "canvas/availabilityChanged")] + CanvasAvailabilityChanged(Box), + #[serde(rename = "canvas/trustChanged")] + CanvasTrustChanged(CanvasTrustChangedAction), + #[serde(rename = "canvas/incarnationChanged")] + CanvasIncarnationChanged(CanvasIncarnationChangedAction), + #[serde(rename = "canvas/titleChanged")] + CanvasTitleChanged(CanvasTitleChangedAction), /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. /// Reducers treat this as a no-op. #[serde(untagged)] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index e3dfe255..f5d3629d 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -292,6 +292,23 @@ pub struct InitializeResult { /// host does not expose an automation catalogue or automation commands. #[serde(default, skip_serializing_if = "Option::is_none")] pub automations: Option, + /// Host/runtime-owned local-canvas support. Presence means the SERVER + /// currently has a working runtime able to serve `openCanvas` / + /// `invokeCanvasAction` for at least one qualifying (explicitly installed + /// and trust-eligible) extension/package source; absence means the host + /// has no available canvas runtime, and clients MUST treat every canvas as + /// {@link CanvasAvailabilityStatus.Unsupported} regardless of what + /// {@link ClientCapabilities.canvases} declared. + /// + /// **Protocol version support alone is not a runtime capability**: a host + /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// assumed to have a usable canvas runtime. This field — not the + /// negotiated `protocolVersion` — is the authoritative signal, and is + /// independent of any individual canvas's live availability + /// ({@link CanvasAvailabilityState}) or trust decision + /// ({@link CanvasTrustState}). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option, } /// Optional capabilities a client declares during `initialize`. @@ -315,6 +332,24 @@ pub struct ClientCapabilities { /// App-bearing tool calls as ordinary MCP tool calls. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp_apps: Option, + /// Client can render local canvases: `listCanvasTypes`, `openCanvas`, + /// subscribe to the resulting `ahp-canvas:` channel, and drive + /// `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + /// `closeCanvas`. + /// + /// Hosts SHOULD NOT offer canvas admission to a client that omits this + /// capability; such a client MUST be treated as if every canvas were + /// {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + /// anything about server/runtime execution trust — see + /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. + /// + /// This declares only the CLIENT's rendering capability. Protocol version + /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// actually has a working canvas runtime — see + /// {@link InitializeResult.canvases}, the server-side counterpart, which a + /// client MUST also check before treating canvases as usable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option, } /// Automation features supported by this host authority. @@ -346,6 +381,13 @@ pub struct AutomationCapabilities { pub run_history_limit: Option, } +/// Local-canvas runtime features supported by this host authority. The empty +/// object means "supported" — see {@link InitializeResult.canvases} for what +/// presence/absence of this field itself means. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCapabilities {} + /// Presence capability for {@link AutomationCreateRequestedAction | /// `automation/createRequested`}. /// @@ -1676,6 +1718,295 @@ pub struct FetchAutomationRunsParams { #[serde(rename_all = "camelCase")] pub struct FetchAutomationRunsResult {} +/// Discovers canvas TYPES currently available to open for one exact backing +/// chat. +/// +/// This is a **pure read/browse** operation: it MUST NOT open, materialize, +/// or otherwise admit any canvas — see `openCanvas` for that. It is +/// unrelated to {@link SessionState.canvases}, which reflects durable +/// membership of already-opened canvas INSTANCES, not the set of canvas +/// TYPES a host/extension could open; do not confuse the two. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCanvasTypesParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Maximum number of entries to return in this page. The server SHOULD respect + /// this bound but MAY return fewer entries and MAY impose its own upper cap. + /// Omit to let the server choose the page size. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + /// Omit to fetch the first page. Cursors are server-defined and MUST be treated + /// as opaque — do not parse, modify, or persist them across connections. An + /// unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +/// Available canvas types for the requested chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCanvasTypesResult { + /// Opaque cursor for the next page. Present when more entries exist beyond the + /// returned page; absent signals the end of the collection. Pass it back as + /// {@link PaginatedParams.cursor} to fetch the following page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Discovered canvas type declarations. + pub types: Vec, +} + +/// Explicitly opens (admits) a canvas, associating it with the owning chat +/// given by `identity.chat` at the moment of the call — never with whichever +/// chat later happens to have focus. +/// +/// This is a read-write admission, not a resolve: unlike `subscribe` (which +/// only reads current state), `openCanvas` is the operation that creates +/// durable membership. There is no implicit open — a client MUST call this +/// before a canvas appears in {@link SessionState.canvases}. Once admitted, +/// clients read and follow live state by `subscribe`-ing to the returned +/// `canvas.resource`, and resolve the current live endpoint via +/// `resolveCanvasSource`; neither read itself opens, resumes, or restarts +/// anything. +/// +/// **Logical identity is always singular.** The same {@link CanvasIdentityKey} +/// (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same +/// `canvas` resource URI and the same {@link SessionState.canvases} catalog +/// entry, no matter how many times `openCanvas` is called for it — the server +/// MUST return that existing entry's `resource` rather than mint a second +/// one. A client-supplied `canvas` URI is honored only on the call that first +/// establishes the identity; on a later call for an already-recorded +/// identity the server MUST ignore the supplied `canvas` value and return the +/// existing resource instead. +/// +/// **Idempotency is scoped to `requestId`, not identity.** Retrying with the +/// exact same `requestId` and byte-for-byte identical params from the same +/// authenticated connection MUST return the original result without +/// repeating any side effect, within a bounded live window (the server is +/// not required to remember it forever). Reusing the same `requestId` with +/// any different parameter value MUST be rejected with `Conflict` +/// (`-32011`) — mint a new `requestId` for a new logical call. A genuinely +/// NEW `requestId` for an already-open identity MAY be effectful (e.g. +/// updating `title`/`icon`, or causing the provider to re-run its own +/// open-time initialization with new `input`) — this mirrors the pinned +/// SDK's own repeated-open behavior and does not create a second logical +/// identity. There is no exactly-once-across-crash guarantee: a lost reply +/// is indeterminate, and clients MUST NOT automatically replay `openCanvas` +/// — reconnect and read `SessionState.canvases` / `resolveCanvasSource` +/// instead to determine the actual outcome. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCanvasParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + pub canvas: Uri, + /// Logical identity to open or re-admit. + pub identity: CanvasIdentityKey, + /// Initial (or updated, on a later effectful call) display title. + pub title: String, + /// Initial (or updated) display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Bounded JSON input for this open call (e.g. seed parameters the + /// provider uses to initialize the canvas), opaque to the protocol. See + /// {@link CanvasTypeDeclaration.openInputSchema} / + /// `openInputSchemaRef` for the expected shape. The JSON-serialized value + /// MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Durable client-generated idempotency key bounding retry deduplication + /// for this call within a live window; see the idempotency rules above. + /// MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, +} + +/// Result identifying the existing or newly opened canvas. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCanvasResult { + /// The catalog entry for the opened (or already-open) canvas. + pub canvas: CanvasEntry, +} + +/// Pure, read-only read of a canvas's current live-resolution state and, +/// when currently live, a transient endpoint presentation. +/// +/// This MUST NOT create, resume, reopen, or restart a provider. If the +/// canvas does not currently have a live endpoint, `source` is absent and +/// `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`) — +/// call `restartCanvasProvider` (an explicitly effectful operation) to +/// attempt recovery instead. A client-local page reload (re-navigating the +/// client's own rendering surface to the same still-live `source.url`) +/// needs no dedicated command at all; calling `resolveCanvasSource` again is +/// also how a client retries resolving a currently-unavailable source +/// without restarting anything. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveCanvasSourceParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// The canvas's current live-resolution state as of this read. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveCanvasSourceResult { + /// Current {@link CanvasEntry.availability}. + pub availability: CanvasAvailabilityStatus, + /// Current {@link CanvasIdentity.incarnation}. + pub incarnation: String, + /// Current {@link CanvasEntry.revision}. + pub revision: i64, + /// Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +/// Invokes one of a canvas's currently declared actions exactly once. +/// +/// The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's +/// current trust is not `trusted`, and with `NotFound` (`-32008`) if +/// `actionId` does not match a currently declared action. `incarnation` is +/// REQUIRED — omitting stale-generation protection on an effectful call is +/// not allowed. If it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` +/// (`-32011`) rather than route the call to a superseded endpoint. +/// +/// The result is the provider's raw reply and is never persisted into +/// `CanvasState` — large or provider-specific payloads stay off the durable +/// state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be +/// represented out of band instead of being returned inline. Any resulting +/// state changes (e.g. a subsequent availability transition) flow back +/// separately through the normal `canvas/*` action stream on the canvas's +/// own channel. +/// +/// A lost reply (e.g. a dropped connection after the provider already ran +/// the handler) is **indeterminate**: clients MUST NOT automatically replay +/// `invokeCanvasAction` on reconnect. Instead, reconnect and read the +/// canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and +/// decide from observed `revision`/`incarnation` and any provider-visible +/// side effect whether to surface the ambiguity to the user, rather than +/// assuming success or failure. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InvokeCanvasActionParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + pub action_id: String, + /// Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + /// if any. The JSON-serialized value MUST NOT exceed + /// `CANVAS_INPUT_MAX_LENGTH`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Expected {@link CanvasIdentity.incarnation}. Required — see above. The + /// server MUST reject the call with `Conflict` if the canvas's live + /// endpoint has since been superseded, rather than deliver the call to it. + pub incarnation: String, + /// Durable client-generated idempotency key bounding retry + /// deduplication for this invocation within a live window. The server is + /// not required to guarantee exactly-once execution across a crash. MUST + /// NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, +} + +/// Result of invoking a declared canvas action. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InvokeCanvasActionResult { + /// The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + pub result: AnyValue, +} + +/// Explicitly restarts the provider/chat-scoped runtime backing this canvas: +/// retires the current live endpoint and establishes a fresh one for the +/// same logical instance. +/// +/// This is the **only** operation that intentionally causes an +/// {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` +/// (read-only source resolution / client-local page reload) MUST NEVER +/// trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} +/// (transitioning through `notLoaded`/`loading`) and then +/// {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart +/// never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or +/// restore any prior in-flight effect. +/// +/// `incarnation` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, so a caller cannot restart a +/// generation it never observed (e.g. after racing a concurrent restart). A +/// lost reply is indeterminate; clients MUST NOT automatically replay this +/// command — reconnect and compare the canvas's current `incarnation` +/// instead. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestartCanvasProviderParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, + /// Expected current {@link CanvasIdentity.incarnation}; required — see above. + pub incarnation: String, +} + +/// Logically closes a canvas: removes its durable membership from +/// `SessionState.canvases` and disposes matching views. +/// +/// This is distinct from a client merely hiding a local tab or view, which is +/// presentation-only and MUST NOT dispatch this command. There is no +/// advertised model tool for this operation — it is invoked only by +/// UI/RPC callers. +/// +/// `revision` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasEntry.revision}, so a caller cannot close membership state it +/// never actually observed. If no matching entry exists (e.g. already +/// closed), the server MUST treat this as a successful no-op rather than an +/// error — the `revision` precondition only applies when an entry still +/// exists. A lost reply is indeterminate; clients MUST NOT automatically +/// replay this command — reconnect and check `SessionState.canvases` +/// instead. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseCanvasParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, + /// Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + pub revision: i64, +} + // ─── ChatSource Union ───────────────────────────────────────────────── /// How a new chat uses a source chat. diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 6d7ff245..14f52e61 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1423,6 +1423,156 @@ pub enum AutomationRunOriginKind { Trigger, } +/// Discriminant for {@link CanvasSource} — what kind of package originates a +/// canvas type. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CanvasSourceKind { + /// An explicitly installed host extension. + Extension, + /// An explicitly installed package (not a host extension). + Package, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for CanvasSourceKind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Extension => serializer.serialize_str("extension"), + Self::Package => serializer.serialize_str("package"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for CanvasSourceKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "extension" => Self::Extension, + "package" => Self::Package, + _ => Self::Unknown(raw), + }) + } +} + +/// Discriminant for {@link CanvasTrustState} — whether the host currently +/// permits this canvas's declared actions to execute. +/// +/// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +/// a canvas may be perfectly capable of rendering while blocked from +/// executing actions, and vice versa. Trust decisions are host/runtime +/// authority, not something this protocol grants. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CanvasTrustStatus { + /// Declared actions may be invoked. + Trusted, + /// A trust decision has not yet been made (e.g. first use of a new/changed source). + Pending, + /// The host has denied execution; declared actions MUST NOT be invoked. + Blocked, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for CanvasTrustStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Trusted => serializer.serialize_str("trusted"), + Self::Pending => serializer.serialize_str("pending"), + Self::Blocked => serializer.serialize_str("blocked"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for CanvasTrustStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "trusted" => Self::Trusted, + "pending" => Self::Pending, + "blocked" => Self::Blocked, + _ => Self::Unknown(raw), + }) + } +} + +/// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +/// live resolution state, independent of its durable +/// {@link CanvasEntry | membership} in a session's catalog. +/// +/// An empty catalog membership list is not itself a close, and a canvas may +/// remain a recorded member while its live availability cycles through these +/// states any number of times (e.g. across provider restarts). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CanvasAvailabilityStatus { + /// The connected client or host does not support this canvas type (e.g. + /// the client omitted the `canvases` capability, or no local runtime can + /// render this `canvasType`). Distinct from `blocked` trust, which is a + /// policy decision rather than a capability gap. + Unsupported, + /// Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + NotLoaded, + /// Currently resolving or (re)connecting to a live endpoint. + Loading, + /// Live and reachable, but the provider has not yet produced content to render. + Empty, + /// Live, reachable, and has declared its current actions. + Ready, + /// The live endpoint failed to resolve, or resolution otherwise failed. + Failed, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for CanvasAvailabilityStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Unsupported => serializer.serialize_str("unsupported"), + Self::NotLoaded => serializer.serialize_str("notLoaded"), + Self::Loading => serializer.serialize_str("loading"), + Self::Empty => serializer.serialize_str("empty"), + Self::Ready => serializer.serialize_str("ready"), + Self::Failed => serializer.serialize_str("failed"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for CanvasAvailabilityStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "unsupported" => Self::Unsupported, + "notLoaded" => Self::NotLoaded, + "loading" => Self::Loading, + "empty" => Self::Empty, + "ready" => Self::Ready, + "failed" => Self::Failed, + _ => Self::Unknown(raw), + }) + } +} + // ─── Structs ────────────────────────────────────────────────────────── /// An optionally-sized icon that can be displayed in a user interface. @@ -2084,6 +2234,14 @@ pub struct SessionState { /// {@link /guide/changesets | Changesets} for an overview of the model. #[serde(default, skip_serializing_if = "Option::is_none")] pub changesets: Option>, + /// Catalog of canvases opened for chats in this session. Presence is + /// durable logical membership, admitted only via `openCanvas` — never + /// implied by a chat's existence or a client's earlier focus. Each entry's + /// {@link CanvasIdentity.chat | `identity.chat`} identifies the exact + /// backing chat; a canvas never migrates to a different chat. See + /// {@link CanvasEntry} for the full membership/availability/trust model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option>, /// Outstanding input the session is blocked on, aggregated across every chat /// so a client can discover and answer it from the session channel alone, /// without subscribing to individual chats. @@ -5647,6 +5805,330 @@ pub struct AutomationRunState { pub meta: Option, } +/// A canvas type provided by an installed host extension. +/// +/// `extensionId` is the identity-bearing field for comparison purposes (see +/// {@link CanvasIdentityKey}). `version` is display/informational metadata +/// only — it MUST NOT be treated as identity-bearing (two `CanvasSource` +/// values that differ only in `version` are the same source). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasExtensionSource { + /// Stable extension identifier (host-defined format, e.g. `publisher.name`). + /// MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub extension_id: String, + /// Installed extension version, when known. Metadata only — not identity-bearing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// A canvas type provided by an installed package that is not a host +/// extension (e.g. a workspace-declared runtime package). +/// +/// `sourceId` — not `packageName` — is the identity-bearing field: the same +/// declared package name MAY be installed in more than one scope (e.g. a +/// workspace-local copy and a globally-installed copy, or two different +/// registries), and each such installation is a distinct source with its own +/// `sourceId`. `packageName` and `version` are display/informational metadata +/// only and MUST NOT be treated as identity-bearing. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasPackageSource { + /// Stable, host- or package-manager-assigned unique identifier for this + /// specific installed package instance/scope (opaque format). This is the + /// identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub source_id: String, + /// Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + pub package_name: String, + /// Installed package version, when known. Metadata only — not identity-bearing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// The logical identity of a canvas, excluding the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +/// +/// Two canvases are the same logical canvas iff `chat`, `canvasType`, +/// `instanceId`, and `source`'s **identity-bearing** fields are all equal: +/// `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` +/// plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and +/// `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into +/// this comparison. Clients MUST NOT treat +/// {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — +/// it is only unique within the scope of `(chat, source, canvasType)`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasIdentityKey { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + pub chat: Uri, + /// The extension or package that declares this canvas's type. + pub source: CanvasSource, + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub canvas_type: String, + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub instance_id: String, +} + +/// Full identity of a canvas, including the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasIdentity { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + pub chat: Uri, + /// The extension or package that declares this canvas's type. + pub source: CanvasSource, + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub canvas_type: String, + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub instance_id: String, + /// Opaque, host-generated token identifying the current generation of this + /// canvas's live endpoint. The host mints a fresh token whenever a provider + /// restart retires the previous live endpoint and establishes a new one for + /// the same logical instance (see {@link CanvasIncarnationChangedAction | + /// `canvas/incarnationChanged`}); it is not changed by a plain page reload + /// against the same still-live endpoint. + /// + /// `incarnation` is **opaque**: clients and hosts MUST compare it only for + /// equality, never parse it, sort it, or perform arithmetic on it (e.g. it + /// is not guaranteed to be numeric or monotonically increasing). The host + /// MUST NOT reuse a token for this logical identity once it has been + /// superseded, including across a host/process restart — if the host + /// cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + /// or timestamp-derived) that make accidental reuse practically + /// impossible, rather than a small resettable counter. + /// + /// Clients and hosts use `incarnation` to reject stale callbacks and + /// in-flight effects addressed to a superseded endpoint. + pub incarnation: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTrustedState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasPendingTrustState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CanvasBlockedTrustState { + /// Optional human-readable reason surfaced to the user. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// One action a canvas declares it can perform, invoked via +/// `invokeCanvasAction`. +/// +/// Declarations are carried only on the full {@link CanvasState}, loaded when +/// a client subscribes — never duplicated into the lightweight +/// {@link CanvasEntry} catalog entry, keeping session summaries small. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasActionDeclaration { + /// Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + pub id: String, + /// Human-readable display name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Description of what invoking the action does. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Inline JSON Schema for the expected `input`, when small enough to embed + /// (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + /// checked by {@link isCanvasSchemaWithinLimits}). Optional because some + /// declared actions take no input. Mutually exclusive with + /// `inputSchemaRef` — a declaration MUST supply at most one of the two. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Bounded out-of-band reference to a larger JSON Schema, used instead of + /// `inputSchema` when the schema would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. AHP does not mandate a specific resolution mechanism for this + /// URI (e.g. a host MAY make it `resourceRead`-able). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema_ref: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasUnsupportedAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasNotLoadedAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasLoadingAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasEmptyAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReadyAvailabilityState { + /// Actions currently declared by the live provider (full replacement each time this state is produced). + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasFailedAvailabilityState { + /// Stable machine-readable and human-readable failure information. + pub error: ErrorInfo, +} + +/// Lightweight catalog entry for a canvas, carried in +/// {@link SessionState.canvases | `SessionState.canvases`}. Presence +/// represents durable **logical membership** — it is unaffected by the live +/// {@link CanvasEntry.availability | `availability`} cycling through +/// `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. +/// +/// The full state, including declared actions, lives in {@link CanvasState}, +/// loaded when a client subscribes to {@link CanvasEntry.resource}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasEntry { + /// Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + pub resource: Uri, + /// Full identity, including current incarnation. + pub identity: CanvasIdentity, + /// Human-readable display title. + pub title: String, + /// Optional display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Current trust decision matching {@link CanvasState.trust}. + pub trust: CanvasTrustState, + /// Current availability status matching {@link CanvasState.availability}'s discriminant. + pub availability: CanvasAvailabilityStatus, + /// Monotonically increasing counter bumped on every change to this + /// canvas's state (trust, availability, or incarnation). Clients MAY use it + /// to detect and reject stale reads without a full deep comparison. + pub revision: i64, + /// Opaque host-defined summary metadata. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Full state for a single canvas, loaded when a client subscribes to the +/// canvas's URI. +/// +/// `CanvasState` **denormalizes** every {@link CanvasEntry} field directly +/// onto itself, replacing `availability`'s lightweight status with the full +/// {@link CanvasAvailabilityState} (including declared actions or failure +/// detail). Producers MUST keep the two representations consistent: any +/// change to the inlined fields SHOULD also be announced on the owning +/// session via {@link SessionCanvasSetAction | `session/canvasSet`}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasState { + /// URI of this canvas channel. + pub resource: Uri, + /// Full identity, including current incarnation. + pub identity: CanvasIdentity, + /// Human-readable display title. + pub title: String, + /// Optional display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Current trust decision. + pub trust: CanvasTrustState, + /// Current live resolution state. + pub availability: CanvasAvailabilityState, + /// Matches {@link CanvasEntry.revision}. + pub revision: i64, + /// Opaque host-defined metadata. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// A canvas type an installed extension or package currently makes available +/// to open for a chat, as returned by `listCanvasTypes`. +/// +/// `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is +/// unrelated to {@link CanvasEntry}, which represents durable membership of +/// an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the +/// catalogue (via `listCanvasTypes`) never opens, materializes, or restarts +/// anything; only `openCanvas` does. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTypeDeclaration { + /// The extension or package that declares this canvas type. + pub source: CanvasSource, + /// Provider-declared canvas type (host/provider-defined format), passed as + /// {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub canvas_type: String, + /// Human-readable display name for a canvas-type picker. + pub title: String, + /// Description of what this canvas type does. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Inline JSON Schema describing the `openCanvas` `input` this type + /// expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + /// / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + /// `openInputSchemaRef`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_input_schema: Option, + /// Bounded out-of-band reference to a larger open-input JSON Schema, used + /// instead of `openInputSchema` when it would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_input_schema_ref: Option, + /// Advisory, statically-known preview of actions this canvas type + /// typically declares once opened (bounded to + /// {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + /// the actual invocable actions for an opened instance are always + /// {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + /// depend on live provider configuration) and MUST be used instead of this + /// preview once the canvas is open. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub declared_actions: Option>, +} + +/// Transient, renderer-neutral presentation of a canvas's current live +/// endpoint, returned by `resolveCanvasSource`. +/// +/// This is a plain URL, not any renderer- or process-model-specific handle +/// (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview +/// panel reference) — how a client actually presents it (a VS Code Webview, +/// the Integrated Browser, or otherwise) is entirely a client/host +/// implementation detail outside this protocol. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasSourcePresentation { + /// Ephemeral URL to the canvas's current live endpoint. Transient — MUST + /// NOT be persisted, cached beyond the current read, or treated as a + /// stable/durable identity. A host MAY embed short-lived, single-use + /// credentials in it; such credentials are never durable authority. + pub url: String, + /// Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + // ─── Customization Enablement Union ─────────────────────────────────────── /// A single explicit customization enablement decision. @@ -6074,6 +6556,58 @@ pub enum AutomationRunLifecycle { Cancelled(AutomationCancelledRunLifecycle), } +/// Identifies the explicitly installed extension or package that declares a canvas type. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum CanvasSource { + #[serde(rename = "extension")] + Extension(CanvasExtensionSource), + #[serde(rename = "package")] + Package(CanvasPackageSource), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + +/// Current trust decision governing whether a canvas's declared actions may execute. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status")] +pub enum CanvasTrustState { + #[serde(rename = "trusted")] + Trusted(CanvasTrustedState), + #[serde(rename = "pending")] + Pending(CanvasPendingTrustState), + #[serde(rename = "blocked")] + Blocked(CanvasBlockedTrustState), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + +/// Current live resolution state of a canvas. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status")] +pub enum CanvasAvailabilityState { + #[serde(rename = "unsupported")] + Unsupported(CanvasUnsupportedAvailabilityState), + #[serde(rename = "notLoaded")] + NotLoaded(CanvasNotLoadedAvailabilityState), + #[serde(rename = "loading")] + Loading(CanvasLoadingAvailabilityState), + #[serde(rename = "empty")] + Empty(CanvasEmptyAvailabilityState), + #[serde(rename = "ready")] + Ready(CanvasReadyAvailabilityState), + #[serde(rename = "failed")] + Failed(CanvasFailedAvailabilityState), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + /// The state payload of a snapshot. /// /// Deserialized by trying session first (has required `lifecycle`), then diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 15cc81d3..042f4492 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.9.0"; +pub const PROTOCOL_VERSION: &str = "0.10.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,5 +13,6 @@ pub const PROTOCOL_VERSION: &str = "0.9.0"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = - &["0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1", +]; diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index b93f3893..a756c2ac 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,6 +2,7 @@ "client": "rust", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 3abc0a93..8a6d6145 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -102,6 +102,12 @@ public enum ActionType: Codable, Sendable, Equatable { case automationRunSessionRemoved case automationRunPrimarySessionChanged case automationRunCancelRequested + case sessionCanvasSet + case sessionCanvasRemoved + case canvasAvailabilityChanged + case canvasTrustChanged + case canvasIncarnationChanged + case canvasTitleChanged /// Unknown raw value from a newer protocol version, preserved verbatim. case unknown(String) @@ -205,6 +211,12 @@ public enum ActionType: Codable, Sendable, Equatable { case "automationRun/sessionRemoved": self = .automationRunSessionRemoved case "automationRun/primarySessionChanged": self = .automationRunPrimarySessionChanged case "automationRun/cancelRequested": self = .automationRunCancelRequested + case "session/canvasSet": self = .sessionCanvasSet + case "session/canvasRemoved": self = .sessionCanvasRemoved + case "canvas/availabilityChanged": self = .canvasAvailabilityChanged + case "canvas/trustChanged": self = .canvasTrustChanged + case "canvas/incarnationChanged": self = .canvasIncarnationChanged + case "canvas/titleChanged": self = .canvasTitleChanged default: self = .unknown(raw) } } @@ -308,6 +320,12 @@ public enum ActionType: Codable, Sendable, Equatable { case .automationRunSessionRemoved: try container.encode("automationRun/sessionRemoved") case .automationRunPrimarySessionChanged: try container.encode("automationRun/primarySessionChanged") case .automationRunCancelRequested: try container.encode("automationRun/cancelRequested") + case .sessionCanvasSet: try container.encode("session/canvasSet") + case .sessionCanvasRemoved: try container.encode("session/canvasRemoved") + case .canvasAvailabilityChanged: try container.encode("canvas/availabilityChanged") + case .canvasTrustChanged: try container.encode("canvas/trustChanged") + case .canvasIncarnationChanged: try container.encode("canvas/incarnationChanged") + case .canvasTitleChanged: try container.encode("canvas/titleChanged") case .unknown(let raw): try container.encode(raw) } } @@ -2344,6 +2362,110 @@ public struct AutomationRunCancelRequestedAction: Codable, Sendable { } } +public struct SessionCanvasSetAction: Codable, Sendable { + public var type: ActionType + /// The canvas entry to add or update, matched by `resource`. + public var canvas: CanvasEntry + + public init( + type: ActionType, + canvas: CanvasEntry + ) { + self.type = type + self.canvas = canvas + } +} + +public struct SessionCanvasRemovedAction: Codable, Sendable { + public var type: ActionType + /// Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + public var resource: String + + public init( + type: ActionType, + resource: String + ) { + self.type = type + self.resource = resource + } +} + +public struct CanvasAvailabilityChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasState.availability}. + public var availability: CanvasAvailabilityState + /// The {@link CanvasState.revision} this action results in. The reducer + /// MUST reject (no-op) this action if `revision` is not strictly greater + /// than the canvas's current `revision` — this is how stale/out-of-order + /// deliveries are consistently rejected across every canvas action, not + /// just this one. + public var revision: Int + + public init( + type: ActionType, + availability: CanvasAvailabilityState, + revision: Int + ) { + self.type = type + self.availability = availability + self.revision = revision + } +} + +public struct CanvasTrustChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasState.trust}. + public var trust: CanvasTrustState + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public var revision: Int + + public init( + type: ActionType, + trust: CanvasTrustState, + revision: Int + ) { + self.type = type + self.trust = trust + self.revision = revision + } +} + +public struct CanvasIncarnationChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + public var incarnation: String + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public var revision: Int + + public init( + type: ActionType, + incarnation: String, + revision: Int + ) { + self.type = type + self.incarnation = incarnation + self.revision = revision + } +} + +public struct CanvasTitleChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasState.title}. + public var title: String + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public var revision: Int + + public init( + type: ActionType, + title: String, + revision: Int + ) { + self.type = type + self.title = title + self.revision = revision + } +} + // MARK: - Partial Summary Types public struct PartialChatSummary: Codable, Sendable { @@ -2490,6 +2612,12 @@ public enum StateAction: Codable, Sendable { case automationRunSessionRemoved(AutomationRunSessionRemovedAction) case automationRunPrimarySessionChanged(AutomationRunPrimarySessionChangedAction) case automationRunCancelRequested(AutomationRunCancelRequestedAction) + case sessionCanvasSet(SessionCanvasSetAction) + case sessionCanvasRemoved(SessionCanvasRemovedAction) + case canvasAvailabilityChanged(CanvasAvailabilityChangedAction) + case canvasTrustChanged(CanvasTrustChangedAction) + case canvasIncarnationChanged(CanvasIncarnationChangedAction) + case canvasTitleChanged(CanvasTitleChangedAction) /// Unknown or future action type; reducers treat this as a no-op. /// The raw payload (including its `type` discriminant) is preserved /// as an `AnyCodable` so a decode→encode round-trip re-emits it @@ -2694,6 +2822,18 @@ public enum StateAction: Codable, Sendable { self = .automationRunPrimarySessionChanged(try AutomationRunPrimarySessionChangedAction(from: decoder)) case "automationRun/cancelRequested": self = .automationRunCancelRequested(try AutomationRunCancelRequestedAction(from: decoder)) + case "session/canvasSet": + self = .sessionCanvasSet(try SessionCanvasSetAction(from: decoder)) + case "session/canvasRemoved": + self = .sessionCanvasRemoved(try SessionCanvasRemovedAction(from: decoder)) + case "canvas/availabilityChanged": + self = .canvasAvailabilityChanged(try CanvasAvailabilityChangedAction(from: decoder)) + case "canvas/trustChanged": + self = .canvasTrustChanged(try CanvasTrustChangedAction(from: decoder)) + case "canvas/incarnationChanged": + self = .canvasIncarnationChanged(try CanvasIncarnationChangedAction(from: decoder)) + case "canvas/titleChanged": + self = .canvasTitleChanged(try CanvasTitleChangedAction(from: decoder)) default: self = .unknown(try AnyCodable(from: decoder)) } @@ -2797,6 +2937,12 @@ public enum StateAction: Codable, Sendable { case .automationRunSessionRemoved(let v): try v.encode(to: encoder) case .automationRunPrimarySessionChanged(let v): try v.encode(to: encoder) case .automationRunCancelRequested(let v): try v.encode(to: encoder) + case .sessionCanvasSet(let v): try v.encode(to: encoder) + case .sessionCanvasRemoved(let v): try v.encode(to: encoder) + case .canvasAvailabilityChanged(let v): try v.encode(to: encoder) + case .canvasTrustChanged(let v): try v.encode(to: encoder) + case .canvasIncarnationChanged(let v): try v.encode(to: encoder) + case .canvasTitleChanged(let v): try v.encode(to: encoder) case .unknown(let value): try value.encode(to: encoder) } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 9d9ca3e2..541284c8 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -341,6 +341,22 @@ public struct InitializeResult: Codable, Sendable { /// `ahp-automations://` for {@link AutomationState}; absence means the /// host does not expose an automation catalogue or automation commands. public var automations: AutomationCapabilities? + /// Host/runtime-owned local-canvas support. Presence means the SERVER + /// currently has a working runtime able to serve `openCanvas` / + /// `invokeCanvasAction` for at least one qualifying (explicitly installed + /// and trust-eligible) extension/package source; absence means the host + /// has no available canvas runtime, and clients MUST treat every canvas as + /// {@link CanvasAvailabilityStatus.Unsupported} regardless of what + /// {@link ClientCapabilities.canvases} declared. + /// + /// **Protocol version support alone is not a runtime capability**: a host + /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// assumed to have a usable canvas runtime. This field — not the + /// negotiated `protocolVersion` — is the authoritative signal, and is + /// independent of any individual canvas's live availability + /// ({@link CanvasAvailabilityState}) or trust decision + /// ({@link CanvasTrustState}). + public var canvases: CanvasCapabilities? enum CodingKeys: String, CodingKey { case protocolVersion @@ -353,6 +369,7 @@ public struct InitializeResult: Codable, Sendable { case terminalCommandPrefix case telemetry case automations + case canvases } public init( @@ -365,7 +382,8 @@ public struct InitializeResult: Codable, Sendable { completionTriggerCharacters: [String]? = nil, terminalCommandPrefix: String? = nil, telemetry: TelemetryCapabilities? = nil, - automations: AutomationCapabilities? = nil + automations: AutomationCapabilities? = nil, + canvases: CanvasCapabilities? = nil ) { self.protocolVersion = protocolVersion self.serverSeq = serverSeq @@ -377,6 +395,7 @@ public struct InitializeResult: Codable, Sendable { self.terminalCommandPrefix = terminalCommandPrefix self.telemetry = telemetry self.automations = automations + self.canvases = canvases } } @@ -393,11 +412,30 @@ public struct ClientCapabilities: Codable, Sendable { /// capability is declared. Clients that omit it MUST treat /// App-bearing tool calls as ordinary MCP tool calls. public var mcpApps: [String: AnyCodable]? + /// Client can render local canvases: `listCanvasTypes`, `openCanvas`, + /// subscribe to the resulting `ahp-canvas:` channel, and drive + /// `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + /// `closeCanvas`. + /// + /// Hosts SHOULD NOT offer canvas admission to a client that omits this + /// capability; such a client MUST be treated as if every canvas were + /// {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + /// anything about server/runtime execution trust — see + /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. + /// + /// This declares only the CLIENT's rendering capability. Protocol version + /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// actually has a working canvas runtime — see + /// {@link InitializeResult.canvases}, the server-side counterpart, which a + /// client MUST also check before treating canvases as usable. + public var canvases: [String: AnyCodable]? public init( - mcpApps: [String: AnyCodable]? = nil + mcpApps: [String: AnyCodable]? = nil, + canvases: [String: AnyCodable]? = nil ) { self.mcpApps = mcpApps + self.canvases = canvases } } @@ -427,6 +465,14 @@ public struct AutomationCapabilities: Codable, Sendable { } } +public struct CanvasCapabilities: Codable, Sendable { + + public init( + + ) { + } +} + public struct AutomationCreateCapability: Codable, Sendable { public init( @@ -2090,6 +2136,296 @@ public struct FetchAutomationRunsResult: Codable, Sendable { } } +public struct ListCanvasTypesParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Maximum number of entries to return in this page. The server SHOULD respect + /// this bound but MAY return fewer entries and MAY impose its own upper cap. + /// Omit to let the server choose the page size. + public var limit: Int? + /// Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + /// Omit to fetch the first page. Cursors are server-defined and MUST be treated + /// as opaque — do not parse, modify, or persist them across connections. An + /// unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + public var cursor: String? + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case limit + case cursor + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + limit: Int? = nil, + cursor: String? = nil + ) { + self.channel = channel + self.meta = meta + self.limit = limit + self.cursor = cursor + } +} + +public struct ListCanvasTypesResult: Codable, Sendable { + /// Opaque cursor for the next page. Present when more entries exist beyond the + /// returned page; absent signals the end of the collection. Pass it back as + /// {@link PaginatedParams.cursor} to fetch the following page. + public var nextCursor: String? + /// Discovered canvas type declarations. + public var types: [CanvasTypeDeclaration] + + public init( + nextCursor: String? = nil, + types: [CanvasTypeDeclaration] + ) { + self.nextCursor = nextCursor + self.types = types + } +} + +public struct OpenCanvasParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + public var canvas: String + /// Logical identity to open or re-admit. + public var identity: CanvasIdentityKey + /// Initial (or updated, on a later effectful call) display title. + public var title: String + /// Initial (or updated) display icon. + public var icon: Icon? + /// Bounded JSON input for this open call (e.g. seed parameters the + /// provider uses to initialize the canvas), opaque to the protocol. See + /// {@link CanvasTypeDeclaration.openInputSchema} / + /// `openInputSchemaRef` for the expected shape. The JSON-serialized value + /// MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + public var input: AnyCodable? + /// Durable client-generated idempotency key bounding retry deduplication + /// for this call within a live window; see the idempotency rules above. + /// MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case canvas + case identity + case title + case icon + case input + case requestId + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + canvas: String, + identity: CanvasIdentityKey, + title: String, + icon: Icon? = nil, + input: AnyCodable? = nil, + requestId: String + ) { + self.channel = channel + self.meta = meta + self.canvas = canvas + self.identity = identity + self.title = title + self.icon = icon + self.input = input + self.requestId = requestId + } +} + +public struct OpenCanvasResult: Codable, Sendable { + /// The catalog entry for the opened (or already-open) canvas. + public var canvas: CanvasEntry + + public init( + canvas: CanvasEntry + ) { + self.canvas = canvas + } +} + +public struct ResolveCanvasSourceParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil + ) { + self.channel = channel + self.meta = meta + } +} + +public struct ResolveCanvasSourceResult: Codable, Sendable { + /// Current {@link CanvasEntry.availability}. + public var availability: CanvasAvailabilityStatus + /// Current {@link CanvasIdentity.incarnation}. + public var incarnation: String + /// Current {@link CanvasEntry.revision}. + public var revision: Int + /// Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + public var source: CanvasSourcePresentation? + + public init( + availability: CanvasAvailabilityStatus, + incarnation: String, + revision: Int, + source: CanvasSourcePresentation? = nil + ) { + self.availability = availability + self.incarnation = incarnation + self.revision = revision + self.source = source + } +} + +public struct InvokeCanvasActionParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + public var actionId: String + /// Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + /// if any. The JSON-serialized value MUST NOT exceed + /// `CANVAS_INPUT_MAX_LENGTH`. + public var input: AnyCodable? + /// Expected {@link CanvasIdentity.incarnation}. Required — see above. The + /// server MUST reject the call with `Conflict` if the canvas's live + /// endpoint has since been superseded, rather than deliver the call to it. + public var incarnation: String + /// Durable client-generated idempotency key bounding retry + /// deduplication for this invocation within a live window. The server is + /// not required to guarantee exactly-once execution across a crash. MUST + /// NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case actionId + case input + case incarnation + case requestId + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + actionId: String, + input: AnyCodable? = nil, + incarnation: String, + requestId: String + ) { + self.channel = channel + self.meta = meta + self.actionId = actionId + self.input = input + self.incarnation = incarnation + self.requestId = requestId + } +} + +public struct InvokeCanvasActionResult: Codable, Sendable { + /// The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + public var result: AnyCodable + + public init( + result: AnyCodable + ) { + self.result = result + } +} + +public struct RestartCanvasProviderParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + /// Expected current {@link CanvasIdentity.incarnation}; required — see above. + public var incarnation: String + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case requestId + case incarnation + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + requestId: String, + incarnation: String + ) { + self.channel = channel + self.meta = meta + self.requestId = requestId + self.incarnation = incarnation + } +} + +public struct CloseCanvasParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + /// Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + public var revision: Int + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case requestId + case revision + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + requestId: String, + revision: Int + ) { + self.channel = channel + self.meta = meta + self.requestId = requestId + self.revision = revision + } +} + // MARK: - Command Unions public enum ChatSource: Codable, Sendable { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 313783d6..eff12027 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1101,6 +1101,129 @@ public enum AutomationRunOriginKind: String, Codable, Sendable { case trigger = "trigger" } +/// Discriminant for {@link CanvasSource} — what kind of package originates a +/// canvas type. +public enum CanvasSourceKind: Codable, Sendable, Equatable { + /// An explicitly installed host extension. + case `extension` + /// An explicitly installed package (not a host extension). + case package + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "extension": self = .`extension` + case "package": self = .package + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .`extension`: try container.encode("extension") + case .package: try container.encode("package") + case .unknown(let raw): try container.encode(raw) + } + } +} + +/// Discriminant for {@link CanvasTrustState} — whether the host currently +/// permits this canvas's declared actions to execute. +/// +/// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +/// a canvas may be perfectly capable of rendering while blocked from +/// executing actions, and vice versa. Trust decisions are host/runtime +/// authority, not something this protocol grants. +public enum CanvasTrustStatus: Codable, Sendable, Equatable { + /// Declared actions may be invoked. + case trusted + /// A trust decision has not yet been made (e.g. first use of a new/changed source). + case pending + /// The host has denied execution; declared actions MUST NOT be invoked. + case blocked + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "trusted": self = .trusted + case "pending": self = .pending + case "blocked": self = .blocked + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .trusted: try container.encode("trusted") + case .pending: try container.encode("pending") + case .blocked: try container.encode("blocked") + case .unknown(let raw): try container.encode(raw) + } + } +} + +/// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +/// live resolution state, independent of its durable +/// {@link CanvasEntry | membership} in a session's catalog. +/// +/// An empty catalog membership list is not itself a close, and a canvas may +/// remain a recorded member while its live availability cycles through these +/// states any number of times (e.g. across provider restarts). +public enum CanvasAvailabilityStatus: Codable, Sendable, Equatable { + /// The connected client or host does not support this canvas type (e.g. + /// the client omitted the `canvases` capability, or no local runtime can + /// render this `canvasType`). Distinct from `blocked` trust, which is a + /// policy decision rather than a capability gap. + case unsupported + /// Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + case notLoaded + /// Currently resolving or (re)connecting to a live endpoint. + case loading + /// Live and reachable, but the provider has not yet produced content to render. + case empty + /// Live, reachable, and has declared its current actions. + case ready + /// The live endpoint failed to resolve, or resolution otherwise failed. + case failed + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "unsupported": self = .unsupported + case "notLoaded": self = .notLoaded + case "loading": self = .loading + case "empty": self = .empty + case "ready": self = .ready + case "failed": self = .failed + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .unsupported: try container.encode("unsupported") + case .notLoaded: try container.encode("notLoaded") + case .loading: try container.encode("loading") + case .empty: try container.encode("empty") + case .ready: try container.encode("ready") + case .failed: try container.encode("failed") + case .unknown(let raw): try container.encode(raw) + } + } +} + // MARK: - State Types public struct Icon: Codable, Sendable { @@ -1867,6 +1990,13 @@ public struct SessionState: Codable, Sendable { /// before subscribing. See {@link Changeset} for the full shape and /// {@link /guide/changesets | Changesets} for an overview of the model. public var changesets: [Changeset]? + /// Catalog of canvases opened for chats in this session. Presence is + /// durable logical membership, admitted only via `openCanvas` — never + /// implied by a chat's existence or a client's earlier focus. Each entry's + /// {@link CanvasIdentity.chat | `identity.chat`} identifies the exact + /// backing chat; a canvas never migrates to a different chat. See + /// {@link CanvasEntry} for the full membership/availability/trust model. + public var canvases: [CanvasEntry]? /// Outstanding input the session is blocked on, aggregated across every chat /// so a client can discover and answer it from the session channel alone, /// without subscribing to individual chats. @@ -1910,6 +2040,7 @@ public struct SessionState: Codable, Sendable { case config case customizations case changesets + case canvases case inputNeeded case meta = "_meta" } @@ -1932,6 +2063,7 @@ public struct SessionState: Codable, Sendable { config: SessionConfigState? = nil, customizations: [Customization]? = nil, changesets: [Changeset]? = nil, + canvases: [CanvasEntry]? = nil, inputNeeded: [SessionInputRequest]? = nil, meta: [String: AnyCodable]? = nil ) { @@ -1952,6 +2084,7 @@ public struct SessionState: Codable, Sendable { self.config = config self.customizations = customizations self.changesets = changesets + self.canvases = canvases self.inputNeeded = inputNeeded self.meta = meta } @@ -6627,6 +6760,439 @@ public struct AutomationRunState: Codable, Sendable { } } +public struct CanvasExtensionSource: Codable, Sendable { + public var kind: CanvasSourceKind + /// Stable extension identifier (host-defined format, e.g. `publisher.name`). + /// MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var extensionId: String + /// Installed extension version, when known. Metadata only — not identity-bearing. + public var version: String? + + public init( + kind: CanvasSourceKind, + extensionId: String, + version: String? = nil + ) { + self.kind = kind + self.extensionId = extensionId + self.version = version + } +} + +public struct CanvasPackageSource: Codable, Sendable { + public var kind: CanvasSourceKind + /// Stable, host- or package-manager-assigned unique identifier for this + /// specific installed package instance/scope (opaque format). This is the + /// identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var sourceId: String + /// Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + public var packageName: String + /// Installed package version, when known. Metadata only — not identity-bearing. + public var version: String? + + public init( + kind: CanvasSourceKind, + sourceId: String, + packageName: String, + version: String? = nil + ) { + self.kind = kind + self.sourceId = sourceId + self.packageName = packageName + self.version = version + } +} + +public struct CanvasIdentityKey: Codable, Sendable { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public var chat: String + /// The extension or package that declares this canvas's type. + public var source: CanvasSource + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var canvasType: String + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var instanceId: String + + public init( + chat: String, + source: CanvasSource, + canvasType: String, + instanceId: String + ) { + self.chat = chat + self.source = source + self.canvasType = canvasType + self.instanceId = instanceId + } +} + +public struct CanvasIdentity: Codable, Sendable { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public var chat: String + /// The extension or package that declares this canvas's type. + public var source: CanvasSource + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var canvasType: String + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var instanceId: String + /// Opaque, host-generated token identifying the current generation of this + /// canvas's live endpoint. The host mints a fresh token whenever a provider + /// restart retires the previous live endpoint and establishes a new one for + /// the same logical instance (see {@link CanvasIncarnationChangedAction | + /// `canvas/incarnationChanged`}); it is not changed by a plain page reload + /// against the same still-live endpoint. + /// + /// `incarnation` is **opaque**: clients and hosts MUST compare it only for + /// equality, never parse it, sort it, or perform arithmetic on it (e.g. it + /// is not guaranteed to be numeric or monotonically increasing). The host + /// MUST NOT reuse a token for this logical identity once it has been + /// superseded, including across a host/process restart — if the host + /// cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + /// or timestamp-derived) that make accidental reuse practically + /// impossible, rather than a small resettable counter. + /// + /// Clients and hosts use `incarnation` to reject stale callbacks and + /// in-flight effects addressed to a superseded endpoint. + public var incarnation: String + + public init( + chat: String, + source: CanvasSource, + canvasType: String, + instanceId: String, + incarnation: String + ) { + self.chat = chat + self.source = source + self.canvasType = canvasType + self.instanceId = instanceId + self.incarnation = incarnation + } +} + +public struct CanvasTrustedState: Codable, Sendable { + public var status: CanvasTrustStatus + + public init( + status: CanvasTrustStatus + ) { + self.status = status + } +} + +public struct CanvasPendingTrustState: Codable, Sendable { + public var status: CanvasTrustStatus + + public init( + status: CanvasTrustStatus + ) { + self.status = status + } +} + +public struct CanvasBlockedTrustState: Codable, Sendable { + public var status: CanvasTrustStatus + /// Optional human-readable reason surfaced to the user. + public var reason: String? + + public init( + status: CanvasTrustStatus, + reason: String? = nil + ) { + self.status = status + self.reason = reason + } +} + +public struct CanvasActionDeclaration: Codable, Sendable { + /// Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + public var id: String + /// Human-readable display name. + public var title: String? + /// Description of what invoking the action does. + public var description: String? + /// Inline JSON Schema for the expected `input`, when small enough to embed + /// (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + /// checked by {@link isCanvasSchemaWithinLimits}). Optional because some + /// declared actions take no input. Mutually exclusive with + /// `inputSchemaRef` — a declaration MUST supply at most one of the two. + public var inputSchema: AnyCodable? + /// Bounded out-of-band reference to a larger JSON Schema, used instead of + /// `inputSchema` when the schema would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. AHP does not mandate a specific resolution mechanism for this + /// URI (e.g. a host MAY make it `resourceRead`-able). + public var inputSchemaRef: String? + + public init( + id: String, + title: String? = nil, + description: String? = nil, + inputSchema: AnyCodable? = nil, + inputSchemaRef: String? = nil + ) { + self.id = id + self.title = title + self.description = description + self.inputSchema = inputSchema + self.inputSchemaRef = inputSchemaRef + } +} + +public struct CanvasUnsupportedAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasNotLoadedAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasLoadingAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasEmptyAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasReadyAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + /// Actions currently declared by the live provider (full replacement each time this state is produced). + public var actions: [CanvasActionDeclaration] + + public init( + status: CanvasAvailabilityStatus, + actions: [CanvasActionDeclaration] + ) { + self.status = status + self.actions = actions + } +} + +public struct CanvasFailedAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + /// Stable machine-readable and human-readable failure information. + public var error: ErrorInfo + + public init( + status: CanvasAvailabilityStatus, + error: ErrorInfo + ) { + self.status = status + self.error = error + } +} + +public struct CanvasEntry: Codable, Sendable { + /// Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + public var resource: String + /// Full identity, including current incarnation. + public var identity: CanvasIdentity + /// Human-readable display title. + public var title: String + /// Optional display icon. + public var icon: Icon? + /// Current trust decision matching {@link CanvasState.trust}. + public var trust: CanvasTrustState + /// Current availability status matching {@link CanvasState.availability}'s discriminant. + public var availability: CanvasAvailabilityStatus + /// Monotonically increasing counter bumped on every change to this + /// canvas's state (trust, availability, or incarnation). Clients MAY use it + /// to detect and reject stale reads without a full deep comparison. + public var revision: Int + /// Opaque host-defined summary metadata. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case resource + case identity + case title + case icon + case trust + case availability + case revision + case meta = "_meta" + } + + public init( + resource: String, + identity: CanvasIdentity, + title: String, + icon: Icon? = nil, + trust: CanvasTrustState, + availability: CanvasAvailabilityStatus, + revision: Int, + meta: [String: AnyCodable]? = nil + ) { + self.resource = resource + self.identity = identity + self.title = title + self.icon = icon + self.trust = trust + self.availability = availability + self.revision = revision + self.meta = meta + } +} + +public struct CanvasState: Codable, Sendable { + /// URI of this canvas channel. + public var resource: String + /// Full identity, including current incarnation. + public var identity: CanvasIdentity + /// Human-readable display title. + public var title: String + /// Optional display icon. + public var icon: Icon? + /// Current trust decision. + public var trust: CanvasTrustState + /// Current live resolution state. + public var availability: CanvasAvailabilityState + /// Matches {@link CanvasEntry.revision}. + public var revision: Int + /// Opaque host-defined metadata. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case resource + case identity + case title + case icon + case trust + case availability + case revision + case meta = "_meta" + } + + public init( + resource: String, + identity: CanvasIdentity, + title: String, + icon: Icon? = nil, + trust: CanvasTrustState, + availability: CanvasAvailabilityState, + revision: Int, + meta: [String: AnyCodable]? = nil + ) { + self.resource = resource + self.identity = identity + self.title = title + self.icon = icon + self.trust = trust + self.availability = availability + self.revision = revision + self.meta = meta + } +} + +public struct CanvasTypeDeclaration: Codable, Sendable { + /// The extension or package that declares this canvas type. + public var source: CanvasSource + /// Provider-declared canvas type (host/provider-defined format), passed as + /// {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var canvasType: String + /// Human-readable display name for a canvas-type picker. + public var title: String + /// Description of what this canvas type does. + public var description: String? + /// Optional display icon. + public var icon: Icon? + /// Inline JSON Schema describing the `openCanvas` `input` this type + /// expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + /// / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + /// `openInputSchemaRef`. + public var openInputSchema: AnyCodable? + /// Bounded out-of-band reference to a larger open-input JSON Schema, used + /// instead of `openInputSchema` when it would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. + public var openInputSchemaRef: String? + /// Advisory, statically-known preview of actions this canvas type + /// typically declares once opened (bounded to + /// {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + /// the actual invocable actions for an opened instance are always + /// {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + /// depend on live provider configuration) and MUST be used instead of this + /// preview once the canvas is open. + public var declaredActions: [CanvasActionDeclaration]? + + public init( + source: CanvasSource, + canvasType: String, + title: String, + description: String? = nil, + icon: Icon? = nil, + openInputSchema: AnyCodable? = nil, + openInputSchemaRef: String? = nil, + declaredActions: [CanvasActionDeclaration]? = nil + ) { + self.source = source + self.canvasType = canvasType + self.title = title + self.description = description + self.icon = icon + self.openInputSchema = openInputSchema + self.openInputSchemaRef = openInputSchemaRef + self.declaredActions = declaredActions + } +} + +public struct CanvasSourcePresentation: Codable, Sendable { + /// Ephemeral URL to the canvas's current live endpoint. Transient — MUST + /// NOT be persisted, cached beyond the current read, or treated as a + /// stable/durable identity. A host MAY embed short-lived, single-use + /// credentials in it; such credentials are never durable authority. + public var url: String + /// Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity. + public var expiresAt: String? + + public init( + url: String, + expiresAt: String? = nil + ) { + self.url = url + self.expiresAt = expiresAt + } +} + // MARK: - Customization Enablement Union /// A single explicit customization enablement decision. @@ -7662,6 +8228,156 @@ public enum AutomationRunLifecycle: Codable, Sendable { } } +public enum CanvasSource: Codable, Sendable { + case extension(CanvasExtensionSource) + case package(CanvasPackageSource) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "extension": + self = .extension(try CanvasExtensionSource(from: decoder)) + case "package": + self = .package(try CanvasPackageSource(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .extension(var value): + value.kind = .extension + try value.encode(to: encoder) + case .package(var value): + value.kind = .package + try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + +public enum CanvasTrustState: Codable, Sendable { + case trusted(CanvasTrustedState) + case pending(CanvasPendingTrustState) + case blocked(CanvasBlockedTrustState) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "status" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "trusted": + self = .trusted(try CanvasTrustedState(from: decoder)) + case "pending": + self = .pending(try CanvasPendingTrustState(from: decoder)) + case "blocked": + self = .blocked(try CanvasBlockedTrustState(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .trusted(var value): + value.status = .trusted + try value.encode(to: encoder) + case .pending(var value): + value.status = .pending + try value.encode(to: encoder) + case .blocked(var value): + value.status = .blocked + try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + +public enum CanvasAvailabilityState: Codable, Sendable { + case unsupported(CanvasUnsupportedAvailabilityState) + case notLoaded(CanvasNotLoadedAvailabilityState) + case loading(CanvasLoadingAvailabilityState) + case empty(CanvasEmptyAvailabilityState) + case ready(CanvasReadyAvailabilityState) + case failed(CanvasFailedAvailabilityState) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "status" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "unsupported": + self = .unsupported(try CanvasUnsupportedAvailabilityState(from: decoder)) + case "notLoaded": + self = .notLoaded(try CanvasNotLoadedAvailabilityState(from: decoder)) + case "loading": + self = .loading(try CanvasLoadingAvailabilityState(from: decoder)) + case "empty": + self = .empty(try CanvasEmptyAvailabilityState(from: decoder)) + case "ready": + self = .ready(try CanvasReadyAvailabilityState(from: decoder)) + case "failed": + self = .failed(try CanvasFailedAvailabilityState(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .unsupported(var value): + value.status = .unsupported + try value.encode(to: encoder) + case .notLoaded(var value): + value.status = .notLoaded + try value.encode(to: encoder) + case .loading(var value): + value.status = .loading + try value.encode(to: encoder) + case .empty(var value): + value.status = .empty + try value.encode(to: encoder) + case .ready(var value): + value.status = .ready + try value.encode(to: encoder) + case .failed(var value): + value.status = .failed + try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + public enum ToolResultContent: Codable, Sendable { case text(ToolResultTextContent) case embeddedResource(ToolResultEmbeddedResourceContent) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index b8527850..35825e38 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.9.0" +public let PROTOCOL_VERSION: String = "0.10.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first. The first entry equals @@ -13,6 +13,7 @@ public let PROTOCOL_VERSION: String = "0.9.0" /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index e5693c0d..485b6ec3 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,6 +2,7 @@ "client": "swift", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index 1925c929..c7d90aa5 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,6 +2,7 @@ "client": "typescript", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/docs/.changes/20260909-local-canvas-protocol.json b/docs/.changes/20260909-local-canvas-protocol.json new file mode 100644 index 00000000..e52282d2 --- /dev/null +++ b/docs/.changes/20260909-local-canvas-protocol.json @@ -0,0 +1,5 @@ +{ + "type": "added", + "message": "`ahp-canvas:` channel for local canvases: stable `CanvasIdentity` (exact backing chat, extension/package source with a dedicated stable `sourceId`, canvas type, instance, and an opaque host-generated `incarnation` token), a `CanvasTrustState`/`CanvasAvailabilityState` lifecycle with consistent stale-revision rejection, `SessionState.canvases` catalogue membership distinct from the new read-only `listCanvasTypes` type-discovery catalogue, and `openCanvas`/`resolveCanvasSource`/`invokeCanvasAction`/`restartCanvasProvider`/`closeCanvas` commands with requestId-scoped idempotency, required incarnation/revision preconditions, and declared size/depth limits (`CANVAS_SCHEMA_MAX_PROPERTIES`, `CANVAS_SCHEMA_MAX_DEPTH`, `CANVAS_INPUT_MAX_LENGTH`, `CANVAS_RESULT_MAX_LENGTH`). Adds a client-side `ClientCapabilities.canvases` rendering capability and a separate server/runtime-side `InitializeResult.canvases` (`CanvasCapabilities`) capability, so protocol version support alone never implies a usable canvas runtime." +} + diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 70f7a2b5..90a85357 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -351,6 +351,40 @@ "type" ] }, + "SessionCanvasSetAction": { + "type": "object", + "description": "A canvas was admitted (opened) or its catalog entry changed.\n\nUpsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the\nserver dispatches this with the full entry to record a newly opened\ncanvas, or to republish it after a trust/availability/incarnation change\nso subscribers following only the session channel stay in sync with\n{@link CanvasState}. Never client-dispatchable — canvases are admitted\nonly through the `openCanvas` command. A stale/out-of-order delivery\n(`canvas.revision` not strictly greater than the currently-recorded\nentry's revision) MUST be rejected (no-op) rather than overwrite a newer\nentry with older data.", + "properties": { + "type": { + "const": "session/canvasSet" + }, + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The canvas entry to add or update, matched by `resource`." + } + }, + "required": [ + "type", + "canvas" + ] + }, + "SessionCanvasRemovedAction": { + "type": "object", + "description": "A canvas was logically closed.\n\nRemove semantics keyed by `resource`: an unknown URI is a no-op. This\nrepresents durable membership removal, not a client hiding a local\ntab/view — see `closeCanvas`.", + "properties": { + "type": { + "const": "session/canvasRemoved" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}." + } + }, + "required": [ + "type", + "resource" + ] + }, "SessionServerToolsChangedAction": { "type": "object", "description": "Server tools for this session have changed.\n\nFull-replacement semantics: the `tools` array replaces the previous `serverTools` entirely.", @@ -2316,6 +2350,94 @@ "type" ] }, + "CanvasAvailabilityChangedAction": { + "type": "object", + "description": "Replaces the canvas's live resolution state.\n\nDispatched by the host on every availability transition: initial\nresolution after `openCanvas`, provider restart, reload, and failure.", + "properties": { + "type": { + "const": "canvas/availabilityChanged" + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "New {@link CanvasState.availability}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in. The reducer\nMUST reject (no-op) this action if `revision` is not strictly greater\nthan the canvas's current `revision` — this is how stale/out-of-order\ndeliveries are consistently rejected across every canvas action, not\njust this one." + } + }, + "required": [ + "type", + "availability", + "revision" + ] + }, + "CanvasTrustChangedAction": { + "type": "object", + "description": "Replaces the canvas's trust decision.\n\nDispatched by the host whenever the execution-trust decision for this\ncanvas's declared actions changes (e.g. a pending decision resolves, or an\nadministrator revokes a previously trusted source).", + "properties": { + "type": { + "const": "canvas/trustChanged" + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "New {@link CanvasState.trust}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "trust", + "revision" + ] + }, + "CanvasIncarnationChangedAction": { + "type": "object", + "description": "Records that the canvas's live endpoint was replaced by a fresh one for\nthe same logical instance (e.g. the owning provider restarted).\n\nThe host MUST dispatch {@link CanvasAvailabilityChangedAction} to\ntransition through `notLoaded`/`loading` around this change. Receivers\nMUST reject in-flight `invokeCanvasAction` replies and stale server-pushed\ncallbacks addressed to a superseded `incarnation` — because `incarnation`\nis opaque (see {@link CanvasIdentity.incarnation}), that rejection is\ndriven by the accompanying `revision` bump here, not by comparing\n`incarnation` values for order.", + "properties": { + "type": { + "const": "canvas/incarnationChanged" + }, + "incarnation": { + "type": "string", + "description": "New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "incarnation", + "revision" + ] + }, + "CanvasTitleChangedAction": { + "type": "object", + "description": "Replaces the canvas's display title.", + "properties": { + "type": { + "const": "canvas/titleChanged" + }, + "title": { + "type": "string", + "description": "New {@link CanvasState.title}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "title", + "revision" + ] + }, "ChatToolCallConfirmedAction": { "oneOf": [ { @@ -2840,6 +2962,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -3229,6 +3354,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted only via `openCanvas` — never\nimplied by a chat's existence or a client's earlier focus. Each entry's\n{@link CanvasIdentity.chat | `identity.chat`} identifies the exact\nbacking chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -7984,6 +8116,462 @@ "sessions" ] }, + "CanvasExtensionSource": { + "type": "object", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", + "properties": { + "kind": { + "const": "extension" + }, + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "extensionId" + ] + }, + "CanvasPackageSource": { + "type": "object", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", + "properties": { + "kind": { + "const": "package" + }, + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." + }, + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "sourceId", + "packageName" + ] + }, + "CanvasIdentityKey": { + "type": "object", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId" + ] + }, + "CanvasIdentity": { + "type": "object", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nagainst the same still-live endpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) never opens, materializes, or restarts\nanything; only `openCanvas` does.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient — MUST\nNOT be persisted, cached beyond the current read, or treated as a\nstable/durable identity. A host MAY embed short-lived, single-use\ncredentials in it; such credentials are never durable authority." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -8529,6 +9117,54 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "StateAction": { "description": "Discriminated union of all state actions.", "oneOf": [ @@ -8631,6 +9267,12 @@ { "$ref": "#/$defs/SessionMetaChangedAction" }, + { + "$ref": "#/$defs/SessionCanvasSetAction" + }, + { + "$ref": "#/$defs/SessionCanvasRemovedAction" + }, { "$ref": "#/$defs/ChatTurnStartedAction" }, @@ -8822,6 +9464,18 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/CanvasAvailabilityChangedAction" + }, + { + "$ref": "#/$defs/CanvasTrustChangedAction" + }, + { + "$ref": "#/$defs/CanvasIncarnationChangedAction" + }, + { + "$ref": "#/$defs/CanvasTitleChangedAction" } ] }, @@ -9000,6 +9654,18 @@ ], "type": "string", "description": "Operations the host currently permits for an automation.\n\nThe list on {@link AutomationEntry.operations} is authoritative and may\nchange over time. Clients MUST NOT infer permission from capabilities alone:\ncapabilities describe what the host implementation can support, while\noperations describe what is allowed for this particular automation now." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." } } } diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 22902368..42ae646c 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -128,6 +128,11 @@ "type": "object", "additionalProperties": {}, "description": "Client can render\n[MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e.\nit can host the View sandbox, run the `ui/*` protocol against it,\nand forward `mcp://`-channel traffic on the App's behalf.\n\nHosts SHOULD only populate\n{@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`}\n(and expose the corresponding\n{@link McpServerCustomization.channel | `mcp://` channel}) when this\ncapability is declared. Clients that omit it MUST treat\nApp-bearing tool calls as ordinary MCP tool calls." + }, + "canvases": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." } } }, @@ -181,6 +186,10 @@ "automations": { "$ref": "#/$defs/AutomationCapabilities", "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + }, + "canvases": { + "$ref": "#/$defs/CanvasCapabilities", + "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking protocol `>= 0.10.0` without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." } }, "required": [ @@ -189,6 +198,11 @@ "snapshots" ] }, + "CanvasCapabilities": { + "type": "object", + "description": "Local-canvas runtime features supported by this host authority. The empty\nobject means \"supported\" — see {@link InitializeResult.canvases} for what\npresence/absence of this field itself means.", + "properties": {} + }, "AutomationCapabilities": { "type": "object", "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", @@ -1665,6 +1679,259 @@ "description": "Empty acknowledgement; the updated automation state is delivered by action.", "properties": {} }, + "ListCanvasTypesParams": { + "type": "object", + "description": "Discovers canvas TYPES currently available to open for one exact backing\nchat.\n\nThis is a **pure read/browse** operation: it MUST NOT open, materialize,\nor otherwise admit any canvas — see `openCanvas` for that. It is\nunrelated to {@link SessionState.canvases}, which reflects durable\nmembership of already-opened canvas INSTANCES, not the set of canvas\nTYPES a host/extension could open; do not confuse the two.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat to discover available canvas types for." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "limit": { + "type": "number", + "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." + } + }, + "required": [ + "channel" + ] + }, + "ListCanvasTypesResult": { + "type": "object", + "description": "Available canvas types for the requested chat.", + "properties": { + "nextCursor": { + "type": "string", + "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." + }, + "types": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasTypeDeclaration" + }, + "description": "Discovered canvas type declarations." + } + }, + "required": [ + "types" + ] + }, + "OpenCanvasParams": { + "type": "object", + "description": "Explicitly opens (admits) a canvas, associating it with the owning chat\ngiven by `identity.chat` at the moment of the call — never with whichever\nchat later happens to have focus.\n\nThis is a read-write admission, not a resolve: unlike `subscribe` (which\nonly reads current state), `openCanvas` is the operation that creates\ndurable membership. There is no implicit open — a client MUST call this\nbefore a canvas appears in {@link SessionState.canvases}. Once admitted,\nclients read and follow live state by `subscribe`-ing to the returned\n`canvas.resource`, and resolve the current live endpoint via\n`resolveCanvasSource`; neither read itself opens, resumes, or restarts\nanything.\n\n**Logical identity is always singular.** The same {@link CanvasIdentityKey}\n(`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same\n`canvas` resource URI and the same {@link SessionState.canvases} catalog\nentry, no matter how many times `openCanvas` is called for it — the server\nMUST return that existing entry's `resource` rather than mint a second\none. A client-supplied `canvas` URI is honored only on the call that first\nestablishes the identity; on a later call for an already-recorded\nidentity the server MUST ignore the supplied `canvas` value and return the\nexisting resource instead.\n\n**Idempotency is scoped to `requestId`, not identity.** Retrying with the\nexact same `requestId` and byte-for-byte identical params from the same\nauthenticated connection MUST return the original result without\nrepeating any side effect, within a bounded live window (the server is\nnot required to remember it forever). Reusing the same `requestId` with\nany different parameter value MUST be rejected with `Conflict`\n(`-32011`) — mint a new `requestId` for a new logical call. A genuinely\nNEW `requestId` for an already-open identity MAY be effectful (e.g.\nupdating `title`/`icon`, or causing the provider to re-run its own\nopen-time initialization with new `input`) — this mirrors the pinned\nSDK's own repeated-open behavior and does not create a second logical\nidentity. There is no exactly-once-across-crash guarantee: a lost reply\nis indeterminate, and clients MUST NOT automatically replay `openCanvas`\n— reconnect and read `SessionState.canvases` / `resolveCanvasSource`\ninstead to determine the actual outcome.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Session URI that will list the opened canvas in `SessionState.canvases`." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "canvas": { + "$ref": "#/$defs/URI", + "description": "Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentityKey", + "description": "Logical identity to open or re-admit." + }, + "title": { + "type": "string", + "description": "Initial (or updated, on a later effectful call) display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Initial (or updated) display icon." + }, + "input": { + "description": "Bounded JSON input for this open call (e.g. seed parameters the\nprovider uses to initialize the canvas), opaque to the protocol. See\n{@link CanvasTypeDeclaration.openInputSchema} /\n`openInputSchemaRef` for the expected shape. The JSON-serialized value\nMUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key bounding retry deduplication\nfor this call within a live window; see the idempotency rules above.\nMUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." + } + }, + "required": [ + "channel", + "canvas", + "identity", + "title", + "requestId" + ] + }, + "OpenCanvasResult": { + "type": "object", + "description": "Result identifying the existing or newly opened canvas.", + "properties": { + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The catalog entry for the opened (or already-open) canvas." + } + }, + "required": [ + "canvas" + ] + }, + "ResolveCanvasSourceParams": { + "type": "object", + "description": "Pure, read-only read of a canvas's current live-resolution state and,\nwhen currently live, a transient endpoint presentation.\n\nThis MUST NOT create, resume, reopen, or restart a provider. If the\ncanvas does not currently have a live endpoint, `source` is absent and\n`availability` reflects why (e.g. `notLoaded`, `loading`, `failed`) —\ncall `restartCanvasProvider` (an explicitly effectful operation) to\nattempt recovery instead. A client-local page reload (re-navigating the\nclient's own rendering surface to the same still-live `source.url`)\nneeds no dedicated command at all; calling `resolveCanvasSource` again is\nalso how a client retries resolving a currently-unavailable source\nwithout restarting anything.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI (an already-opened canvas's `resource`)." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, + "ResolveCanvasSourceResult": { + "type": "object", + "description": "The canvas's current live-resolution state as of this read.", + "properties": { + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current {@link CanvasEntry.availability}." + }, + "incarnation": { + "type": "string", + "description": "Current {@link CanvasIdentity.incarnation}." + }, + "revision": { + "type": "number", + "description": "Current {@link CanvasEntry.revision}." + }, + "source": { + "$ref": "#/$defs/CanvasSourcePresentation", + "description": "Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}." + } + }, + "required": [ + "availability", + "incarnation", + "revision" + ] + }, + "InvokeCanvasActionParams": { + "type": "object", + "description": "Invokes one of a canvas's currently declared actions exactly once.\n\nThe server MUST reject with `PermissionDenied` (`-32009`) if the canvas's\ncurrent trust is not `trusted`, and with `NotFound` (`-32008`) if\n`actionId` does not match a currently declared action. `incarnation` is\nREQUIRED — omitting stale-generation protection on an effectful call is\nnot allowed. If it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict`\n(`-32011`) rather than route the call to a superseded endpoint.\n\nThe result is the provider's raw reply and is never persisted into\n`CanvasState` — large or provider-specific payloads stay off the durable\nstate tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be\nrepresented out of band instead of being returned inline. Any resulting\nstate changes (e.g. a subsequent availability transition) flow back\nseparately through the normal `canvas/*` action stream on the canvas's\nown channel.\n\nA lost reply (e.g. a dropped connection after the provider already ran\nthe handler) is **indeterminate**: clients MUST NOT automatically replay\n`invokeCanvasAction` on reconnect. Instead, reconnect and read the\ncanvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and\ndecide from observed `revision`/`incarnation` and any provider-visible\nside effect whether to surface the ambiguity to the user, rather than\nassuming success or failure.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "actionId": { + "type": "string", + "description": "Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions." + }, + "input": { + "description": "Input conforming to the declared action's `inputSchema`/`inputSchemaRef`,\nif any. The JSON-serialized value MUST NOT exceed\n`CANVAS_INPUT_MAX_LENGTH`." + }, + "incarnation": { + "type": "string", + "description": "Expected {@link CanvasIdentity.incarnation}. Required — see above. The\nserver MUST reject the call with `Conflict` if the canvas's live\nendpoint has since been superseded, rather than deliver the call to it." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key bounding retry\ndeduplication for this invocation within a live window. The server is\nnot required to guarantee exactly-once execution across a crash. MUST\nNOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." + } + }, + "required": [ + "channel", + "actionId", + "incarnation", + "requestId" + ] + }, + "InvokeCanvasActionResult": { + "type": "object", + "description": "Result of invoking a declared canvas action.", + "properties": { + "result": { + "description": "The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized." + } + }, + "required": [ + "result" + ] + }, + "RestartCanvasProviderParams": { + "type": "object", + "description": "Explicitly restarts the provider/chat-scoped runtime backing this canvas:\nretires the current live endpoint and establishes a fresh one for the\nsame logical instance.\n\nThis is the **only** operation that intentionally causes an\n{@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource`\n(read-only source resolution / client-local page reload) MUST NEVER\ntrigger it. The host dispatches {@link CanvasAvailabilityChangedAction}\n(transitioning through `notLoaded`/`loading`) and then\n{@link CanvasIncarnationChangedAction} to reflect the outcome. Restart\nnever replays a prior `invokeCanvasAction`, and MUST NOT steal focus or\nrestore any prior in-flight effect.\n\n`incarnation` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, so a caller cannot restart a\ngeneration it never observed (e.g. after racing a concurrent restart). A\nlost reply is indeterminate; clients MUST NOT automatically replay this\ncommand — reconnect and compare the canvas's current `incarnation`\ninstead.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." + }, + "incarnation": { + "type": "string", + "description": "Expected current {@link CanvasIdentity.incarnation}; required — see above." + } + }, + "required": [ + "channel", + "requestId", + "incarnation" + ] + }, + "CloseCanvasParams": { + "type": "object", + "description": "Logically closes a canvas: removes its durable membership from\n`SessionState.canvases` and disposes matching views.\n\nThis is distinct from a client merely hiding a local tab or view, which is\npresentation-only and MUST NOT dispatch this command. There is no\nadvertised model tool for this operation — it is invoked only by\nUI/RPC callers.\n\n`revision` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasEntry.revision}, so a caller cannot close membership state it\nnever actually observed. If no matching entry exists (e.g. already\nclosed), the server MUST treat this as a successful no-op rather than an\nerror — the `revision` precondition only applies when an entry still\nexists. A lost reply is indeterminate; clients MUST NOT automatically\nreplay this command — reconnect and check `SessionState.canvases`\ninstead.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." + }, + "revision": { + "type": "number", + "description": "Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above." + } + }, + "required": [ + "channel", + "requestId", + "revision" + ] + }, "Icon": { "type": "object", "description": "An optionally-sized icon that can be displayed in a user interface.", @@ -2084,6 +2351,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -2473,6 +2743,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted only via `openCanvas` — never\nimplied by a chat's existence or a client's earlier focus. Each entry's\n{@link CanvasIdentity.chat | `identity.chat`} identifies the exact\nbacking chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -7228,79 +7505,535 @@ "sessions" ] }, - "ActionOrigin": { + "CanvasExtensionSource": { "type": "object", - "description": "Identifies the client that originally dispatched an action.", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", "properties": { - "clientId": { - "type": "string" + "kind": { + "const": "extension" }, - "clientSeq": { - "type": "number" + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." } }, "required": [ - "clientId", - "clientSeq" + "kind", + "extensionId" ] }, - "ActionEnvelope": { + "CanvasPackageSource": { "type": "object", - "description": "Every action is wrapped in an `ActionEnvelope`.\n\nThe envelope identifies the channel the action belongs to (e.g.\n`ahp-root://` for root actions, the session URI for session actions, the\nterminal URI for terminal actions). Individual action payloads carry only\nfields that are intrinsic to the action; the channel comes from the\nenvelope so that any subscribable resource can route its actions uniformly.", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", "properties": { - "channel": { - "$ref": "#/$defs/URI", - "description": "Channel URI this action belongs to." - }, - "action": { - "$ref": "#/$defs/StateAction" + "kind": { + "const": "package" }, - "serverSeq": { - "type": "number" + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "origin": { - "$ref": "#/$defs/ActionOrigin" + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." }, - "rejectionReason": { - "type": "string" + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." } }, "required": [ - "channel", - "action", - "serverSeq" + "kind", + "sourceId", + "packageName" ] }, - "RootAgentsChangedAction": { + "CanvasIdentityKey": { "type": "object", - "description": "Fired when available agent backends or their models change.", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.", "properties": { - "type": { - "const": "root/agentsChanged" + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "agents": { - "type": "array", - "items": { - "$ref": "#/$defs/AgentInfo" - }, - "description": "Updated agent list" + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." } }, "required": [ - "type", - "agents" + "chat", + "source", + "canvasType", + "instanceId" ] }, - "RootActiveSessionsChangedAction": { + "CanvasIdentity": { "type": "object", - "description": "Fired when the number of active sessions changes.", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", "properties": { - "type": { - "const": "root/activeSessionsChanged" + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "activeSessions": { - "type": "number", - "description": "Current count of active sessions" + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nagainst the same still-live endpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) never opens, materializes, or restarts\nanything; only `openCanvas` does.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient — MUST\nNOT be persisted, cached beyond the current read, or treated as a\nstable/durable identity. A host MAY embed short-lived, single-use\ncredentials in it; such credentials are never durable authority." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "ActionOrigin": { + "type": "object", + "description": "Identifies the client that originally dispatched an action.", + "properties": { + "clientId": { + "type": "string" + }, + "clientSeq": { + "type": "number" + } + }, + "required": [ + "clientId", + "clientSeq" + ] + }, + "ActionEnvelope": { + "type": "object", + "description": "Every action is wrapped in an `ActionEnvelope`.\n\nThe envelope identifies the channel the action belongs to (e.g.\n`ahp-root://` for root actions, the session URI for session actions, the\nterminal URI for terminal actions). Individual action payloads carry only\nfields that are intrinsic to the action; the channel comes from the\nenvelope so that any subscribable resource can route its actions uniformly.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Channel URI this action belongs to." + }, + "action": { + "$ref": "#/$defs/StateAction" + }, + "serverSeq": { + "type": "number" + }, + "origin": { + "$ref": "#/$defs/ActionOrigin" + }, + "rejectionReason": { + "type": "string" + } + }, + "required": [ + "channel", + "action", + "serverSeq" + ] + }, + "RootAgentsChangedAction": { + "type": "object", + "description": "Fired when available agent backends or their models change.", + "properties": { + "type": { + "const": "root/agentsChanged" + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/$defs/AgentInfo" + }, + "description": "Updated agent list" + } + }, + "required": [ + "type", + "agents" + ] + }, + "RootActiveSessionsChangedAction": { + "type": "object", + "description": "Fired when the number of active sessions changes.", + "properties": { + "type": { + "const": "root/activeSessionsChanged" + }, + "activeSessions": { + "type": "number", + "description": "Current count of active sessions" } }, "required": [ @@ -7574,6 +8307,40 @@ "type" ] }, + "SessionCanvasSetAction": { + "type": "object", + "description": "A canvas was admitted (opened) or its catalog entry changed.\n\nUpsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the\nserver dispatches this with the full entry to record a newly opened\ncanvas, or to republish it after a trust/availability/incarnation change\nso subscribers following only the session channel stay in sync with\n{@link CanvasState}. Never client-dispatchable — canvases are admitted\nonly through the `openCanvas` command. A stale/out-of-order delivery\n(`canvas.revision` not strictly greater than the currently-recorded\nentry's revision) MUST be rejected (no-op) rather than overwrite a newer\nentry with older data.", + "properties": { + "type": { + "const": "session/canvasSet" + }, + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The canvas entry to add or update, matched by `resource`." + } + }, + "required": [ + "type", + "canvas" + ] + }, + "SessionCanvasRemovedAction": { + "type": "object", + "description": "A canvas was logically closed.\n\nRemove semantics keyed by `resource`: an unknown URI is a no-op. This\nrepresents durable membership removal, not a client hiding a local\ntab/view — see `closeCanvas`.", + "properties": { + "type": { + "const": "session/canvasRemoved" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}." + } + }, + "required": [ + "type", + "resource" + ] + }, "SessionServerToolsChangedAction": { "type": "object", "description": "Server tools for this session have changed.\n\nFull-replacement semantics: the `tools` array replaces the previous `serverTools` entirely.", @@ -9539,6 +10306,94 @@ "type" ] }, + "CanvasAvailabilityChangedAction": { + "type": "object", + "description": "Replaces the canvas's live resolution state.\n\nDispatched by the host on every availability transition: initial\nresolution after `openCanvas`, provider restart, reload, and failure.", + "properties": { + "type": { + "const": "canvas/availabilityChanged" + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "New {@link CanvasState.availability}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in. The reducer\nMUST reject (no-op) this action if `revision` is not strictly greater\nthan the canvas's current `revision` — this is how stale/out-of-order\ndeliveries are consistently rejected across every canvas action, not\njust this one." + } + }, + "required": [ + "type", + "availability", + "revision" + ] + }, + "CanvasTrustChangedAction": { + "type": "object", + "description": "Replaces the canvas's trust decision.\n\nDispatched by the host whenever the execution-trust decision for this\ncanvas's declared actions changes (e.g. a pending decision resolves, or an\nadministrator revokes a previously trusted source).", + "properties": { + "type": { + "const": "canvas/trustChanged" + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "New {@link CanvasState.trust}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "trust", + "revision" + ] + }, + "CanvasIncarnationChangedAction": { + "type": "object", + "description": "Records that the canvas's live endpoint was replaced by a fresh one for\nthe same logical instance (e.g. the owning provider restarted).\n\nThe host MUST dispatch {@link CanvasAvailabilityChangedAction} to\ntransition through `notLoaded`/`loading` around this change. Receivers\nMUST reject in-flight `invokeCanvasAction` replies and stale server-pushed\ncallbacks addressed to a superseded `incarnation` — because `incarnation`\nis opaque (see {@link CanvasIdentity.incarnation}), that rejection is\ndriven by the accompanying `revision` bump here, not by comparing\n`incarnation` values for order.", + "properties": { + "type": { + "const": "canvas/incarnationChanged" + }, + "incarnation": { + "type": "string", + "description": "New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "incarnation", + "revision" + ] + }, + "CanvasTitleChangedAction": { + "type": "object", + "description": "Replaces the canvas's display title.", + "properties": { + "type": { + "const": "canvas/titleChanged" + }, + "title": { + "type": "string", + "description": "New {@link CanvasState.title}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "title", + "revision" + ] + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -9641,6 +10496,12 @@ { "$ref": "#/$defs/SessionMetaChangedAction" }, + { + "$ref": "#/$defs/SessionCanvasSetAction" + }, + { + "$ref": "#/$defs/SessionCanvasRemovedAction" + }, { "$ref": "#/$defs/ChatTurnStartedAction" }, @@ -9832,6 +10693,18 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/CanvasAvailabilityChangedAction" + }, + { + "$ref": "#/$defs/CanvasTrustChangedAction" + }, + { + "$ref": "#/$defs/CanvasIncarnationChangedAction" + }, + { + "$ref": "#/$defs/CanvasTitleChangedAction" } ], "description": "Discriminated union of all state actions." @@ -9983,6 +10856,18 @@ ], "description": "A string that may optionally be rendered as Markdown.\n\n- A plain `string` is rendered as-is (no Markdown processing).\n- An object with `{ markdown: string }` is rendered with Markdown formatting." }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." + }, "JsonPrimitive": { "oneOf": [ { @@ -10642,6 +11527,54 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "PendingMessageKind": { "enum": [ "steering", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 71cb746b..42cdbc22 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -513,6 +513,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -902,6 +905,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted only via `openCanvas` — never\nimplied by a chat's existence or a client's earlier focus. Each entry's\n{@link CanvasIdentity.chat | `identity.chat`} identifies the exact\nbacking chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -5657,286 +5667,756 @@ "sessions" ] }, - "BaseParams": { + "CanvasExtensionSource": { "type": "object", - "description": "Base shape every command's params extends.\n\n`channel` identifies the channel the command targets, mirroring the\n`channel` field on every protocol notification. For commands that operate\non a specific channel (a session, terminal, or changeset), `channel` is\nthat channel's URI. For commands that are connection-level rather than\nchannel-scoped (e.g. {@link InitializeParams | `initialize`},\n{@link PingParams | `ping`}, {@link ListSessionsParams | `listSessions`},\nthe `resource*` filesystem commands, and {@link AuthenticateParams |\n`authenticate`}), the params type narrows `channel` to the literal\nroot URI `'ahp-root://'`.\n\nThis invariant lets implementations route every incoming message —\nrequest, response, or notification — by inspecting `params.channel`\nwithout needing to know the per-method param shape.", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", "properties": { - "channel": { - "$ref": "#/$defs/URI", - "description": "Channel URI this command targets." + "kind": { + "const": "extension" }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." } }, "required": [ - "channel" + "kind", + "extensionId" ] }, - "PaginatedParams": { + "CanvasPackageSource": { "type": "object", - "description": "Cursor-based pagination inputs, mixed into the params of any list command\nthat can page a large result set (e.g. {@link ListSessionsParams |\n`listSessions`}). The paired output is {@link PaginatedResult}.\n\nPagination is **opaque and cursor-based**, mirroring the shape `fetchTurns`\nalready uses for chat history: the server owns the ordering and keyset, and\nthe client walks pages by echoing the cursor from the previous\n{@link PaginatedResult.nextCursor} back on the next request.\n\nThe contract every paginated command shares:\n\n- To fetch the first page, omit `cursor`. Supply `limit` to bound the page.\n- If the result carries a {@link PaginatedResult.nextCursor}, more entries\n exist — pass it back as `cursor` to fetch the following page. A missing\n `nextCursor` signals the end of the collection.\n- Cursors are **server-defined and opaque**: clients MUST NOT parse, modify,\n or persist them across connections. An unrecognised cursor SHOULD be\n rejected with an `InvalidParams` error.\n- Pagination is **fully additive**: a client that omits `limit`/`cursor` and\n ignores `nextCursor` sees the pre-pagination behaviour (subject to any\n server-imposed cap), and a server that does not paginate ignores the inputs\n and returns everything in a single page.", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", "properties": { - "limit": { - "type": "number", - "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + "kind": { + "const": "package" }, - "cursor": { - "type": "string", - "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." - } - } - }, - "PaginatedResult": { - "type": "object", - "description": "Cursor-based pagination output, extended by the result of any list command\nthat can page a large result set (e.g. {@link ListSessionsResult |\n`listSessions`}). See {@link PaginatedParams} for the full pagination\ncontract shared by every paginated command.", - "properties": { - "nextCursor": { - "type": "string", - "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." - } - } - }, - "Implementation": { - "type": "object", - "description": "Identifies a protocol implementation — the software (and build) on one end\nof the connection, as distinct from the {@link AgentInfo | agent persona} it\nhosts. Carried as {@link InitializeParams.clientInfo | `clientInfo`} on the\nclient side and {@link InitializeResult.serverInfo | `serverInfo`} on the\nserver side, mirroring LSP's `clientInfo`/`serverInfo` and MCP's\n`Implementation`.\n\nThis is **informational only**: it exists for logging, telemetry, an\nabout/status affordance, and — as a last resort — a known-issue workaround\nfor a specific buggy build. It is **not** a feature-detection mechanism.\nFeature availability stays with the capability model\n({@link ClientCapabilities} and the various `*.capabilities` declarations);\nimplementations SHOULD NOT gate protocol behaviour on parsing\n{@link Implementation.version | `version`}.", - "properties": { - "name": { + "sourceId": { "type": "string", - "description": "Implementation name, e.g. a product or package identifier." + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "version": { + "packageName": { "type": "string", - "description": "Implementation version. A [SemVer](https://semver.org) string is\nrecommended but not required." + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." }, - "title": { + "version": { "type": "string", - "description": "Optional human-readable display name." + "description": "Installed package version, when known. Metadata only — not identity-bearing." } }, "required": [ - "name" + "kind", + "sourceId", + "packageName" ] }, - "InitializeParams": { + "CanvasIdentityKey": { "type": "object", - "description": "Establishes a new connection and negotiates the protocol version.\nThis MUST be the first message sent by the client.", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.", "properties": { - "channel": { - "type": "string", - "enum": [ - "ahp-root://" - ] - }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "protocolVersions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects one entry and returns it as `InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." }, - "clientId": { + "canvasType": { "type": "string", - "description": "Unique client identifier" + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "clientInfo": { - "$ref": "#/$defs/Implementation", - "description": "Optional identity of the client implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Distinct from {@link InitializeParams.clientId | `clientId`},\nwhich is an opaque per-connection identifier used for reconnection, not a\nhuman-readable implementation name." - }, - "initialSubscriptions": { - "type": "array", - "items": { - "$ref": "#/$defs/URI" - }, - "description": "URIs to subscribe to during handshake" - }, - "locale": { + "instanceId": { "type": "string", - "description": "IETF BCP 47 language tag indicating the client's preferred locale\n(e.g. `\"en-US\"`, `\"ja\"`). The server SHOULD use this to localise\nuser-facing strings such as confirmation option labels." - }, - "capabilities": { - "$ref": "#/$defs/ClientCapabilities", - "description": "Optional client capability declarations.\n\nServers SHOULD only advertise features whose corresponding client\ncapability is set here. Absent means \"not declared\" — the server\nMUST assume the client does not support the feature." + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." } }, "required": [ - "channel", - "protocolVersions", - "clientId" + "chat", + "source", + "canvasType", + "instanceId" ] }, - "ClientCapabilities": { - "type": "object", - "description": "Optional capabilities a client declares during `initialize`.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities\nare reserved for future per-capability options.", - "properties": { - "mcpApps": { - "type": "object", - "additionalProperties": {}, - "description": "Client can render\n[MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e.\nit can host the View sandbox, run the `ui/*` protocol against it,\nand forward `mcp://`-channel traffic on the App's behalf.\n\nHosts SHOULD only populate\n{@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`}\n(and expose the corresponding\n{@link McpServerCustomization.channel | `mcp://` channel}) when this\ncapability is declared. Clients that omit it MUST treat\nApp-bearing tool calls as ordinary MCP tool calls." - } - } - }, - "InitializeResult": { + "CanvasIdentity": { "type": "object", - "description": "Result of the `initialize` command.\n\n`protocolVersion` is the version the server has selected from the client's\n`protocolVersions` list. The client and server MUST use this version for\nthe rest of the connection. If the server cannot speak any of the offered\nversions it MUST return error code `-32005` (`UnsupportedProtocolVersion`)\nwith required `UnsupportedProtocolVersionErrorData` containing\n`supportedVersions`, instead of a result.", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", "properties": { - "protocolVersion": { - "type": "string", - "description": "Protocol version selected by the server. MUST be one of the entries in\n`InitializeParams.protocolVersions`. Formatted as a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`)." - }, - "serverSeq": { - "type": "number", - "description": "Current server sequence number" - }, - "serverInfo": { - "$ref": "#/$defs/Implementation", - "description": "Optional identity of the server implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Whereas {@link InitializeResult.protocolVersion | `protocolVersion`}\nidentifies the negotiated protocol, `serverInfo` identifies the host\nsoftware behind it." - }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional implementation-specific extension metadata advertised by the host.\n\nHosts and clients MAY agree on namespaced keys for capabilities that are not\npart of the standardized protocol. Clients MUST ignore keys they do not\nunderstand. Capabilities needed for interoperable behavior SHOULD use typed\nfields on {@link InitializeResult} instead." - }, - "snapshots": { - "type": "array", - "items": { - "$ref": "#/$defs/Snapshot" - }, - "description": "Snapshots for each `initialSubscriptions` URI" - }, - "defaultDirectory": { + "chat": { "$ref": "#/$defs/URI", - "description": "Suggested default directory for remote filesystem browsing" + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "completionTriggerCharacters": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Characters that, when typed in a {@link Message} input, SHOULD cause\nthe client to issue a `completions` request with\n{@link CompletionItemKind.UserMessage}. Typically includes characters like\n`'@'` or `'/'`." + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." }, - "terminalCommandPrefix": { + "canvasType": { "type": "string", - "description": "Prefix that the host recognizes at the start of a user {@link Message.text}\nas a shorthand for executing the remainder as a terminal command. Currently\nthe standardized convention is `\"!\"`; absence means the host does not\nsupport command prefixes." + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "telemetry": { - "$ref": "#/$defs/TelemetryCapabilities", - "description": "OTLP telemetry channels the host emits, if any. Each populated field is\neither a literal `ahp-otlp:` channel URI or an RFC 6570 URI template a\nclient expands before subscribing (currently only the `logs` channel\ndefines a template variable, `{level}`, for subscriber-side severity\nfiltering). Clients MAY ignore signals they cannot process." + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "automations": { - "$ref": "#/$defs/AutomationCapabilities", - "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nagainst the same still-live endpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." } }, "required": [ - "protocolVersion", - "serverSeq", - "snapshots" + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" ] }, - "AutomationCapabilities": { + "CanvasTrustedState": { "type": "object", - "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", "properties": { - "create": { - "$ref": "#/$defs/AutomationCreateCapability", - "description": "Present when clients may dispatch {@link AutomationCreateRequestedAction}." - }, - "schedules": { - "$ref": "#/$defs/AutomationScheduleCapabilities", - "description": "Present when definitions may contain {@link AutomationScheduleTrigger | schedule triggers}." - }, - "runCancellation": { - "$ref": "#/$defs/AutomationRunCancellationCapability", - "description": "Present when clients may request cancellation of `pending` or `running`\nautomation runs." - }, - "runHistoryLimit": { - "type": "number", - "description": "Maximum terminal entries retained in {@link AutomationEntry.runs}. Active\nruns are not counted toward the limit. Absence means the retention limit is\nimplementation-defined." + "status": { + "const": "trusted" } - } - }, - "AutomationCreateCapability": { - "type": "object", - "description": "Presence capability for {@link AutomationCreateRequestedAction |\n`automation/createRequested`}.\n\nThe empty object means \"supported\"; fields are reserved for future\ncreate-specific options.", - "properties": {} + }, + "required": [ + "status" + ] }, - "AutomationScheduleCapabilities": { + "CanvasPendingTrustState": { "type": "object", - "description": "Host restrictions on portable {@link AutomationSchedule} triggers.\n\nThe cron grammar itself is fixed by AHP. Hosts MUST accept every expression\nin that grammar unless it violates an advertised interval restriction.", "properties": { - "minIntervalMinutes": { - "type": "number", - "description": "Smallest permitted interval between consecutive occurrences produced by\n{@link AutomationSchedule.expression}. Omission means no restriction beyond\nthe cron format's one-minute resolution." + "status": { + "const": "pending" } - } - }, - "AutomationRunCancellationCapability": { - "type": "object", - "description": "Presence capability for {@link AutomationRunCancelRequestedAction |\n`automationRun/cancelRequested`}.\n\nThe empty object means \"supported.\" Clients may dispatch the action for\n`pending` or `running` runs; terminal runs cannot be cancelled.", - "properties": {} + }, + "required": [ + "status" + ] }, - "PingParams": { + "CanvasBlockedTrustState": { "type": "object", - "description": "Verifies that the AHP connection is still alive and keeps it from being\nclosed by idle-timeout intermediaries (proxies, load balancers, etc.).\n\nThe server MUST respond regardless of whether the client has completed\n`initialize` or holds any subscriptions. Ping carries no payload in either\ndirection; the response itself is the signal.", "properties": { - "channel": { - "type": "string", - "enum": [ - "ahp-root://" - ] + "status": { + "const": "blocked" }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." } }, "required": [ - "channel" + "status" ] }, - "ReconnectParams": { + "CanvasActionDeclaration": { "type": "object", - "description": "Re-establishes a dropped connection. The server replays missed actions or\nprovides fresh snapshots.", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", "properties": { - "channel": { + "id": { "type": "string", - "enum": [ - "ahp-root://" - ] + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." - }, - "clientId": { + "title": { "type": "string", - "description": "Client identifier from the original connection" + "description": "Human-readable display name." }, - "lastSeenServerSeq": { - "type": "number", - "description": "Last `serverSeq` the client received" + "description": { + "type": "string", + "description": "Description of what invoking the action does." }, - "subscriptions": { - "type": "array", - "items": { - "$ref": "#/$defs/URI" - }, - "description": "URIs the client was subscribed to" - } - }, - "required": [ - "channel", - "clientId", + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) never opens, materializes, or restarts\nanything; only `openCanvas` does.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient — MUST\nNOT be persisted, cached beyond the current read, or treated as a\nstable/durable identity. A host MAY embed short-lived, single-use\ncredentials in it; such credentials are never durable authority." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "BaseParams": { + "type": "object", + "description": "Base shape every command's params extends.\n\n`channel` identifies the channel the command targets, mirroring the\n`channel` field on every protocol notification. For commands that operate\non a specific channel (a session, terminal, or changeset), `channel` is\nthat channel's URI. For commands that are connection-level rather than\nchannel-scoped (e.g. {@link InitializeParams | `initialize`},\n{@link PingParams | `ping`}, {@link ListSessionsParams | `listSessions`},\nthe `resource*` filesystem commands, and {@link AuthenticateParams |\n`authenticate`}), the params type narrows `channel` to the literal\nroot URI `'ahp-root://'`.\n\nThis invariant lets implementations route every incoming message —\nrequest, response, or notification — by inspecting `params.channel`\nwithout needing to know the per-method param shape.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Channel URI this command targets." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, + "PaginatedParams": { + "type": "object", + "description": "Cursor-based pagination inputs, mixed into the params of any list command\nthat can page a large result set (e.g. {@link ListSessionsParams |\n`listSessions`}). The paired output is {@link PaginatedResult}.\n\nPagination is **opaque and cursor-based**, mirroring the shape `fetchTurns`\nalready uses for chat history: the server owns the ordering and keyset, and\nthe client walks pages by echoing the cursor from the previous\n{@link PaginatedResult.nextCursor} back on the next request.\n\nThe contract every paginated command shares:\n\n- To fetch the first page, omit `cursor`. Supply `limit` to bound the page.\n- If the result carries a {@link PaginatedResult.nextCursor}, more entries\n exist — pass it back as `cursor` to fetch the following page. A missing\n `nextCursor` signals the end of the collection.\n- Cursors are **server-defined and opaque**: clients MUST NOT parse, modify,\n or persist them across connections. An unrecognised cursor SHOULD be\n rejected with an `InvalidParams` error.\n- Pagination is **fully additive**: a client that omits `limit`/`cursor` and\n ignores `nextCursor` sees the pre-pagination behaviour (subject to any\n server-imposed cap), and a server that does not paginate ignores the inputs\n and returns everything in a single page.", + "properties": { + "limit": { + "type": "number", + "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." + } + } + }, + "PaginatedResult": { + "type": "object", + "description": "Cursor-based pagination output, extended by the result of any list command\nthat can page a large result set (e.g. {@link ListSessionsResult |\n`listSessions`}). See {@link PaginatedParams} for the full pagination\ncontract shared by every paginated command.", + "properties": { + "nextCursor": { + "type": "string", + "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." + } + } + }, + "Implementation": { + "type": "object", + "description": "Identifies a protocol implementation — the software (and build) on one end\nof the connection, as distinct from the {@link AgentInfo | agent persona} it\nhosts. Carried as {@link InitializeParams.clientInfo | `clientInfo`} on the\nclient side and {@link InitializeResult.serverInfo | `serverInfo`} on the\nserver side, mirroring LSP's `clientInfo`/`serverInfo` and MCP's\n`Implementation`.\n\nThis is **informational only**: it exists for logging, telemetry, an\nabout/status affordance, and — as a last resort — a known-issue workaround\nfor a specific buggy build. It is **not** a feature-detection mechanism.\nFeature availability stays with the capability model\n({@link ClientCapabilities} and the various `*.capabilities` declarations);\nimplementations SHOULD NOT gate protocol behaviour on parsing\n{@link Implementation.version | `version`}.", + "properties": { + "name": { + "type": "string", + "description": "Implementation name, e.g. a product or package identifier." + }, + "version": { + "type": "string", + "description": "Implementation version. A [SemVer](https://semver.org) string is\nrecommended but not required." + }, + "title": { + "type": "string", + "description": "Optional human-readable display name." + } + }, + "required": [ + "name" + ] + }, + "InitializeParams": { + "type": "object", + "description": "Establishes a new connection and negotiates the protocol version.\nThis MUST be the first message sent by the client.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ] + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "protocolVersions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects one entry and returns it as `InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." + }, + "clientId": { + "type": "string", + "description": "Unique client identifier" + }, + "clientInfo": { + "$ref": "#/$defs/Implementation", + "description": "Optional identity of the client implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Distinct from {@link InitializeParams.clientId | `clientId`},\nwhich is an opaque per-connection identifier used for reconnection, not a\nhuman-readable implementation name." + }, + "initialSubscriptions": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "URIs to subscribe to during handshake" + }, + "locale": { + "type": "string", + "description": "IETF BCP 47 language tag indicating the client's preferred locale\n(e.g. `\"en-US\"`, `\"ja\"`). The server SHOULD use this to localise\nuser-facing strings such as confirmation option labels." + }, + "capabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "Optional client capability declarations.\n\nServers SHOULD only advertise features whose corresponding client\ncapability is set here. Absent means \"not declared\" — the server\nMUST assume the client does not support the feature." + } + }, + "required": [ + "channel", + "protocolVersions", + "clientId" + ] + }, + "ClientCapabilities": { + "type": "object", + "description": "Optional capabilities a client declares during `initialize`.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities\nare reserved for future per-capability options.", + "properties": { + "mcpApps": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render\n[MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e.\nit can host the View sandbox, run the `ui/*` protocol against it,\nand forward `mcp://`-channel traffic on the App's behalf.\n\nHosts SHOULD only populate\n{@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`}\n(and expose the corresponding\n{@link McpServerCustomization.channel | `mcp://` channel}) when this\ncapability is declared. Clients that omit it MUST treat\nApp-bearing tool calls as ordinary MCP tool calls." + }, + "canvases": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." + } + } + }, + "InitializeResult": { + "type": "object", + "description": "Result of the `initialize` command.\n\n`protocolVersion` is the version the server has selected from the client's\n`protocolVersions` list. The client and server MUST use this version for\nthe rest of the connection. If the server cannot speak any of the offered\nversions it MUST return error code `-32005` (`UnsupportedProtocolVersion`)\nwith required `UnsupportedProtocolVersionErrorData` containing\n`supportedVersions`, instead of a result.", + "properties": { + "protocolVersion": { + "type": "string", + "description": "Protocol version selected by the server. MUST be one of the entries in\n`InitializeParams.protocolVersions`. Formatted as a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`)." + }, + "serverSeq": { + "type": "number", + "description": "Current server sequence number" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Optional identity of the server implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Whereas {@link InitializeResult.protocolVersion | `protocolVersion`}\nidentifies the negotiated protocol, `serverInfo` identifies the host\nsoftware behind it." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific extension metadata advertised by the host.\n\nHosts and clients MAY agree on namespaced keys for capabilities that are not\npart of the standardized protocol. Clients MUST ignore keys they do not\nunderstand. Capabilities needed for interoperable behavior SHOULD use typed\nfields on {@link InitializeResult} instead." + }, + "snapshots": { + "type": "array", + "items": { + "$ref": "#/$defs/Snapshot" + }, + "description": "Snapshots for each `initialSubscriptions` URI" + }, + "defaultDirectory": { + "$ref": "#/$defs/URI", + "description": "Suggested default directory for remote filesystem browsing" + }, + "completionTriggerCharacters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Characters that, when typed in a {@link Message} input, SHOULD cause\nthe client to issue a `completions` request with\n{@link CompletionItemKind.UserMessage}. Typically includes characters like\n`'@'` or `'/'`." + }, + "terminalCommandPrefix": { + "type": "string", + "description": "Prefix that the host recognizes at the start of a user {@link Message.text}\nas a shorthand for executing the remainder as a terminal command. Currently\nthe standardized convention is `\"!\"`; absence means the host does not\nsupport command prefixes." + }, + "telemetry": { + "$ref": "#/$defs/TelemetryCapabilities", + "description": "OTLP telemetry channels the host emits, if any. Each populated field is\neither a literal `ahp-otlp:` channel URI or an RFC 6570 URI template a\nclient expands before subscribing (currently only the `logs` channel\ndefines a template variable, `{level}`, for subscriber-side severity\nfiltering). Clients MAY ignore signals they cannot process." + }, + "automations": { + "$ref": "#/$defs/AutomationCapabilities", + "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + }, + "canvases": { + "$ref": "#/$defs/CanvasCapabilities", + "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking protocol `>= 0.10.0` without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." + } + }, + "required": [ + "protocolVersion", + "serverSeq", + "snapshots" + ] + }, + "CanvasCapabilities": { + "type": "object", + "description": "Local-canvas runtime features supported by this host authority. The empty\nobject means \"supported\" — see {@link InitializeResult.canvases} for what\npresence/absence of this field itself means.", + "properties": {} + }, + "AutomationCapabilities": { + "type": "object", + "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", + "properties": { + "create": { + "$ref": "#/$defs/AutomationCreateCapability", + "description": "Present when clients may dispatch {@link AutomationCreateRequestedAction}." + }, + "schedules": { + "$ref": "#/$defs/AutomationScheduleCapabilities", + "description": "Present when definitions may contain {@link AutomationScheduleTrigger | schedule triggers}." + }, + "runCancellation": { + "$ref": "#/$defs/AutomationRunCancellationCapability", + "description": "Present when clients may request cancellation of `pending` or `running`\nautomation runs." + }, + "runHistoryLimit": { + "type": "number", + "description": "Maximum terminal entries retained in {@link AutomationEntry.runs}. Active\nruns are not counted toward the limit. Absence means the retention limit is\nimplementation-defined." + } + } + }, + "AutomationCreateCapability": { + "type": "object", + "description": "Presence capability for {@link AutomationCreateRequestedAction |\n`automation/createRequested`}.\n\nThe empty object means \"supported\"; fields are reserved for future\ncreate-specific options.", + "properties": {} + }, + "AutomationScheduleCapabilities": { + "type": "object", + "description": "Host restrictions on portable {@link AutomationSchedule} triggers.\n\nThe cron grammar itself is fixed by AHP. Hosts MUST accept every expression\nin that grammar unless it violates an advertised interval restriction.", + "properties": { + "minIntervalMinutes": { + "type": "number", + "description": "Smallest permitted interval between consecutive occurrences produced by\n{@link AutomationSchedule.expression}. Omission means no restriction beyond\nthe cron format's one-minute resolution." + } + } + }, + "AutomationRunCancellationCapability": { + "type": "object", + "description": "Presence capability for {@link AutomationRunCancelRequestedAction |\n`automationRun/cancelRequested`}.\n\nThe empty object means \"supported.\" Clients may dispatch the action for\n`pending` or `running` runs; terminal runs cannot be cancelled.", + "properties": {} + }, + "PingParams": { + "type": "object", + "description": "Verifies that the AHP connection is still alive and keeps it from being\nclosed by idle-timeout intermediaries (proxies, load balancers, etc.).\n\nThe server MUST respond regardless of whether the client has completed\n`initialize` or holds any subscriptions. Ping carries no payload in either\ndirection; the response itself is the signal.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ] + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, + "ReconnectParams": { + "type": "object", + "description": "Re-establishes a dropped connection. The server replays missed actions or\nprovides fresh snapshots.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ] + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "clientId": { + "type": "string", + "description": "Client identifier from the original connection" + }, + "lastSeenServerSeq": { + "type": "number", + "description": "Last `serverSeq` the client received" + }, + "subscriptions": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "URIs the client was subscribed to" + } + }, + "required": [ + "channel", + "clientId", "lastSeenServerSeq", "subscriptions" ] @@ -7151,171 +7631,424 @@ ], "description": "Glob patterns or paths relative to `uri` to exclude from reporting.\nWrapped in `{ items }` for forward compatibility." }, - "includes": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "items" - ], - "description": "Glob patterns or paths relative to `uri` to restrict reporting to.\nOmit to report every change under `uri` subject to `excludes`.\nWrapped in `{ items }` for forward compatibility." + "includes": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "items" + ], + "description": "Glob patterns or paths relative to `uri` to restrict reporting to.\nOmit to report every change under `uri` subject to `excludes`.\nWrapped in `{ items }` for forward compatibility." + } + }, + "required": [ + "channel", + "uri" + ] + }, + "CreateResourceWatchResult": { + "type": "object", + "description": "Result of the `createResourceWatch` command.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Receiver-assigned watch channel URI (`ahp-resource-watch:/`). The\ncaller subscribes to this URI to start receiving change events and\nunsubscribes to release the watcher." + } + }, + "required": [ + "channel" + ] + }, + "ListAutomationTriggerDefinitionsParams": { + "type": "object", + "description": "Discover event-trigger types available for a prospective session template.\n\nHosts may vary definitions by provider, workspace, and session\nconfiguration. Schedule triggers are protocol-defined and therefore do not\nappear in this result. The result describes current authoring and validation\nchoices. Saved {@link AutomationEventTrigger} values retain their selected\nevent descriptors for display but do not establish current availability.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ], + "description": "Trigger definitions are discovered from the root channel." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "provider": { + "type": "string", + "description": "Prospective provider id matching {@link AgentInfo.provider}, or omitted for the host default." + }, + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "Prospective {@link AutomationSessionTemplate.workingDirectories}." + }, + "sessionConfig": { + "type": "object", + "additionalProperties": {}, + "description": "Prospective resolved {@link AutomationSessionTemplate.config}." + } + }, + "required": [ + "channel" + ] + }, + "ListAutomationTriggerDefinitionsResult": { + "type": "object", + "description": "Host-defined event trigger types available for the supplied context.", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/AutomationTriggerDefinition" + }, + "description": "Available event trigger definitions." + } + }, + "required": [ + "items" + ] + }, + "RunAutomationParams": { + "type": "object", + "description": "Start a manual run of an automation.\n\nManual execution is independent of {@link AutomationDefinition.enabled}.\nThe host persists the run before beginning session side effects.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-automations://" + ], + "description": "Manual runs are scoped to the catalogue channel." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "automation": { + "$ref": "#/$defs/URI", + "description": "Target {@link AutomationEntry.resource}." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key. Retrying with the same key and\nautomation MUST return the original run URI rather than create another\nrun." + } + }, + "required": [ + "channel", + "automation", + "requestId" + ] + }, + "RunAutomationResult": { + "type": "object", + "description": "Result identifying the existing or newly created run.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-automation-run:` URI matching {@link AutomationRunState.resource}." + } + }, + "required": [ + "resource" + ] + }, + "FetchAutomationRunsParams": { + "type": "object", + "description": "Load one older page into a catalogued automation's run-history state.\n\nThe response only acknowledges the request. The updated full state arrives\nthrough {@link AutomationSetAction | `automation/set`} on the\n`ahp-automations://` channel, keeping all catalogue subscribers synchronized\nthrough the normal action stream.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-automations://" + ], + "description": "Run-history loading is scoped to the catalogue channel." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "automation": { + "$ref": "#/$defs/URI", + "description": "Target {@link AutomationEntry.resource}." + }, + "cursor": { + "type": "string", + "description": "Cursor previously received as {@link AutomationEntry.runsNextCursor}.\nOmit to request the first page not already included by the snapshot." + } + }, + "required": [ + "channel", + "automation" + ] + }, + "FetchAutomationRunsResult": { + "type": "object", + "description": "Empty acknowledgement; the updated automation state is delivered by action.", + "properties": {} + }, + "ListCanvasTypesParams": { + "type": "object", + "description": "Discovers canvas TYPES currently available to open for one exact backing\nchat.\n\nThis is a **pure read/browse** operation: it MUST NOT open, materialize,\nor otherwise admit any canvas — see `openCanvas` for that. It is\nunrelated to {@link SessionState.canvases}, which reflects durable\nmembership of already-opened canvas INSTANCES, not the set of canvas\nTYPES a host/extension could open; do not confuse the two.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat to discover available canvas types for." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "limit": { + "type": "number", + "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." + } + }, + "required": [ + "channel" + ] + }, + "ListCanvasTypesResult": { + "type": "object", + "description": "Available canvas types for the requested chat.", + "properties": { + "nextCursor": { + "type": "string", + "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." + }, + "types": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasTypeDeclaration" + }, + "description": "Discovered canvas type declarations." + } + }, + "required": [ + "types" + ] + }, + "OpenCanvasParams": { + "type": "object", + "description": "Explicitly opens (admits) a canvas, associating it with the owning chat\ngiven by `identity.chat` at the moment of the call — never with whichever\nchat later happens to have focus.\n\nThis is a read-write admission, not a resolve: unlike `subscribe` (which\nonly reads current state), `openCanvas` is the operation that creates\ndurable membership. There is no implicit open — a client MUST call this\nbefore a canvas appears in {@link SessionState.canvases}. Once admitted,\nclients read and follow live state by `subscribe`-ing to the returned\n`canvas.resource`, and resolve the current live endpoint via\n`resolveCanvasSource`; neither read itself opens, resumes, or restarts\nanything.\n\n**Logical identity is always singular.** The same {@link CanvasIdentityKey}\n(`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same\n`canvas` resource URI and the same {@link SessionState.canvases} catalog\nentry, no matter how many times `openCanvas` is called for it — the server\nMUST return that existing entry's `resource` rather than mint a second\none. A client-supplied `canvas` URI is honored only on the call that first\nestablishes the identity; on a later call for an already-recorded\nidentity the server MUST ignore the supplied `canvas` value and return the\nexisting resource instead.\n\n**Idempotency is scoped to `requestId`, not identity.** Retrying with the\nexact same `requestId` and byte-for-byte identical params from the same\nauthenticated connection MUST return the original result without\nrepeating any side effect, within a bounded live window (the server is\nnot required to remember it forever). Reusing the same `requestId` with\nany different parameter value MUST be rejected with `Conflict`\n(`-32011`) — mint a new `requestId` for a new logical call. A genuinely\nNEW `requestId` for an already-open identity MAY be effectful (e.g.\nupdating `title`/`icon`, or causing the provider to re-run its own\nopen-time initialization with new `input`) — this mirrors the pinned\nSDK's own repeated-open behavior and does not create a second logical\nidentity. There is no exactly-once-across-crash guarantee: a lost reply\nis indeterminate, and clients MUST NOT automatically replay `openCanvas`\n— reconnect and read `SessionState.canvases` / `resolveCanvasSource`\ninstead to determine the actual outcome.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Session URI that will list the opened canvas in `SessionState.canvases`." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "canvas": { + "$ref": "#/$defs/URI", + "description": "Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentityKey", + "description": "Logical identity to open or re-admit." + }, + "title": { + "type": "string", + "description": "Initial (or updated, on a later effectful call) display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Initial (or updated) display icon." + }, + "input": { + "description": "Bounded JSON input for this open call (e.g. seed parameters the\nprovider uses to initialize the canvas), opaque to the protocol. See\n{@link CanvasTypeDeclaration.openInputSchema} /\n`openInputSchemaRef` for the expected shape. The JSON-serialized value\nMUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key bounding retry deduplication\nfor this call within a live window; see the idempotency rules above.\nMUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." } }, "required": [ "channel", - "uri" + "canvas", + "identity", + "title", + "requestId" ] }, - "CreateResourceWatchResult": { + "OpenCanvasResult": { "type": "object", - "description": "Result of the `createResourceWatch` command.", + "description": "Result identifying the existing or newly opened canvas.", "properties": { - "channel": { - "$ref": "#/$defs/URI", - "description": "Receiver-assigned watch channel URI (`ahp-resource-watch:/`). The\ncaller subscribes to this URI to start receiving change events and\nunsubscribes to release the watcher." + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The catalog entry for the opened (or already-open) canvas." } }, "required": [ - "channel" + "canvas" ] }, - "ListAutomationTriggerDefinitionsParams": { + "ResolveCanvasSourceParams": { "type": "object", - "description": "Discover event-trigger types available for a prospective session template.\n\nHosts may vary definitions by provider, workspace, and session\nconfiguration. Schedule triggers are protocol-defined and therefore do not\nappear in this result. The result describes current authoring and validation\nchoices. Saved {@link AutomationEventTrigger} values retain their selected\nevent descriptors for display but do not establish current availability.", + "description": "Pure, read-only read of a canvas's current live-resolution state and,\nwhen currently live, a transient endpoint presentation.\n\nThis MUST NOT create, resume, reopen, or restart a provider. If the\ncanvas does not currently have a live endpoint, `source` is absent and\n`availability` reflects why (e.g. `notLoaded`, `loading`, `failed`) —\ncall `restartCanvasProvider` (an explicitly effectful operation) to\nattempt recovery instead. A client-local page reload (re-navigating the\nclient's own rendering surface to the same still-live `source.url`)\nneeds no dedicated command at all; calling `resolveCanvasSource` again is\nalso how a client retries resolving a currently-unavailable source\nwithout restarting anything.", "properties": { "channel": { - "type": "string", - "enum": [ - "ahp-root://" - ], - "description": "Trigger definitions are discovered from the root channel." + "$ref": "#/$defs/URI", + "description": "The canvas URI (an already-opened canvas's `resource`)." }, "_meta": { "type": "object", "additionalProperties": {}, "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." - }, - "provider": { - "type": "string", - "description": "Prospective provider id matching {@link AgentInfo.provider}, or omitted for the host default." - }, - "workingDirectories": { - "type": "array", - "items": { - "$ref": "#/$defs/URI" - }, - "description": "Prospective {@link AutomationSessionTemplate.workingDirectories}." - }, - "sessionConfig": { - "type": "object", - "additionalProperties": {}, - "description": "Prospective resolved {@link AutomationSessionTemplate.config}." } }, "required": [ "channel" ] }, - "ListAutomationTriggerDefinitionsResult": { + "ResolveCanvasSourceResult": { "type": "object", - "description": "Host-defined event trigger types available for the supplied context.", + "description": "The canvas's current live-resolution state as of this read.", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/AutomationTriggerDefinition" - }, - "description": "Available event trigger definitions." + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current {@link CanvasEntry.availability}." + }, + "incarnation": { + "type": "string", + "description": "Current {@link CanvasIdentity.incarnation}." + }, + "revision": { + "type": "number", + "description": "Current {@link CanvasEntry.revision}." + }, + "source": { + "$ref": "#/$defs/CanvasSourcePresentation", + "description": "Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}." } }, "required": [ - "items" + "availability", + "incarnation", + "revision" ] }, - "RunAutomationParams": { + "InvokeCanvasActionParams": { "type": "object", - "description": "Start a manual run of an automation.\n\nManual execution is independent of {@link AutomationDefinition.enabled}.\nThe host persists the run before beginning session side effects.", + "description": "Invokes one of a canvas's currently declared actions exactly once.\n\nThe server MUST reject with `PermissionDenied` (`-32009`) if the canvas's\ncurrent trust is not `trusted`, and with `NotFound` (`-32008`) if\n`actionId` does not match a currently declared action. `incarnation` is\nREQUIRED — omitting stale-generation protection on an effectful call is\nnot allowed. If it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict`\n(`-32011`) rather than route the call to a superseded endpoint.\n\nThe result is the provider's raw reply and is never persisted into\n`CanvasState` — large or provider-specific payloads stay off the durable\nstate tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be\nrepresented out of band instead of being returned inline. Any resulting\nstate changes (e.g. a subsequent availability transition) flow back\nseparately through the normal `canvas/*` action stream on the canvas's\nown channel.\n\nA lost reply (e.g. a dropped connection after the provider already ran\nthe handler) is **indeterminate**: clients MUST NOT automatically replay\n`invokeCanvasAction` on reconnect. Instead, reconnect and read the\ncanvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and\ndecide from observed `revision`/`incarnation` and any provider-visible\nside effect whether to surface the ambiguity to the user, rather than\nassuming success or failure.", "properties": { "channel": { - "type": "string", - "enum": [ - "ahp-automations://" - ], - "description": "Manual runs are scoped to the catalogue channel." + "$ref": "#/$defs/URI", + "description": "The canvas URI." }, "_meta": { "type": "object", "additionalProperties": {}, "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." }, - "automation": { - "$ref": "#/$defs/URI", - "description": "Target {@link AutomationEntry.resource}." + "actionId": { + "type": "string", + "description": "Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions." + }, + "input": { + "description": "Input conforming to the declared action's `inputSchema`/`inputSchemaRef`,\nif any. The JSON-serialized value MUST NOT exceed\n`CANVAS_INPUT_MAX_LENGTH`." + }, + "incarnation": { + "type": "string", + "description": "Expected {@link CanvasIdentity.incarnation}. Required — see above. The\nserver MUST reject the call with `Conflict` if the canvas's live\nendpoint has since been superseded, rather than deliver the call to it." }, "requestId": { "type": "string", - "description": "Durable client-generated idempotency key. Retrying with the same key and\nautomation MUST return the original run URI rather than create another\nrun." + "description": "Durable client-generated idempotency key bounding retry\ndeduplication for this invocation within a live window. The server is\nnot required to guarantee exactly-once execution across a crash. MUST\nNOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." } }, "required": [ "channel", - "automation", + "actionId", + "incarnation", "requestId" ] }, - "RunAutomationResult": { + "InvokeCanvasActionResult": { "type": "object", - "description": "Result identifying the existing or newly created run.", + "description": "Result of invoking a declared canvas action.", "properties": { - "resource": { - "$ref": "#/$defs/URI", - "description": "Subscribable `ahp-automation-run:` URI matching {@link AutomationRunState.resource}." + "result": { + "description": "The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized." } }, "required": [ - "resource" + "result" ] }, - "FetchAutomationRunsParams": { + "RestartCanvasProviderParams": { "type": "object", - "description": "Load one older page into a catalogued automation's run-history state.\n\nThe response only acknowledges the request. The updated full state arrives\nthrough {@link AutomationSetAction | `automation/set`} on the\n`ahp-automations://` channel, keeping all catalogue subscribers synchronized\nthrough the normal action stream.", + "description": "Explicitly restarts the provider/chat-scoped runtime backing this canvas:\nretires the current live endpoint and establishes a fresh one for the\nsame logical instance.\n\nThis is the **only** operation that intentionally causes an\n{@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource`\n(read-only source resolution / client-local page reload) MUST NEVER\ntrigger it. The host dispatches {@link CanvasAvailabilityChangedAction}\n(transitioning through `notLoaded`/`loading`) and then\n{@link CanvasIncarnationChangedAction} to reflect the outcome. Restart\nnever replays a prior `invokeCanvasAction`, and MUST NOT steal focus or\nrestore any prior in-flight effect.\n\n`incarnation` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, so a caller cannot restart a\ngeneration it never observed (e.g. after racing a concurrent restart). A\nlost reply is indeterminate; clients MUST NOT automatically replay this\ncommand — reconnect and compare the canvas's current `incarnation`\ninstead.", "properties": { "channel": { - "type": "string", - "enum": [ - "ahp-automations://" - ], - "description": "Run-history loading is scoped to the catalogue channel." + "$ref": "#/$defs/URI", + "description": "The canvas URI." }, "_meta": { "type": "object", "additionalProperties": {}, "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." }, - "automation": { - "$ref": "#/$defs/URI", - "description": "Target {@link AutomationEntry.resource}." + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." }, - "cursor": { + "incarnation": { "type": "string", - "description": "Cursor previously received as {@link AutomationEntry.runsNextCursor}.\nOmit to request the first page not already included by the snapshot." + "description": "Expected current {@link CanvasIdentity.incarnation}; required — see above." } }, "required": [ "channel", - "automation" + "requestId", + "incarnation" ] }, - "FetchAutomationRunsResult": { + "CloseCanvasParams": { "type": "object", - "description": "Empty acknowledgement; the updated automation state is delivered by action.", - "properties": {} + "description": "Logically closes a canvas: removes its durable membership from\n`SessionState.canvases` and disposes matching views.\n\nThis is distinct from a client merely hiding a local tab or view, which is\npresentation-only and MUST NOT dispatch this command. There is no\nadvertised model tool for this operation — it is invoked only by\nUI/RPC callers.\n\n`revision` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasEntry.revision}, so a caller cannot close membership state it\nnever actually observed. If no matching entry exists (e.g. already\nclosed), the server MUST treat this as a successful no-op rather than an\nerror — the `revision` precondition only applies when an entry still\nexists. A lost reply is indeterminate; clients MUST NOT automatically\nreplay this command — reconnect and check `SessionState.canvases`\ninstead.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." + }, + "revision": { + "type": "number", + "description": "Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above." + } + }, + "required": [ + "channel", + "requestId", + "revision" + ] }, "URI": { "type": "string", @@ -8030,6 +8763,66 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "ActionEnvelope": { "type": "object", "description": "Every action is wrapped in an `ActionEnvelope`.\n\nThe envelope identifies the channel the action belongs to (e.g.\n`ahp-root://` for root actions, the session URI for session actions, the\nterminal URI for terminal actions). Individual action payloads carry only\nfields that are intrinsic to the action; the channel comes from the\nenvelope so that any subscribable resource can route its actions uniformly.", @@ -8155,6 +8948,12 @@ { "$ref": "#/$defs/SessionMetaChangedAction" }, + { + "$ref": "#/$defs/SessionCanvasSetAction" + }, + { + "$ref": "#/$defs/SessionCanvasRemovedAction" + }, { "$ref": "#/$defs/ChatTurnStartedAction" }, @@ -8346,6 +9145,18 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/CanvasAvailabilityChangedAction" + }, + { + "$ref": "#/$defs/CanvasTrustChangedAction" + }, + { + "$ref": "#/$defs/CanvasIncarnationChangedAction" + }, + { + "$ref": "#/$defs/CanvasTitleChangedAction" } ], "description": "Discriminated union of all state actions." @@ -9088,6 +9899,40 @@ "type" ] }, + "SessionCanvasSetAction": { + "type": "object", + "description": "A canvas was admitted (opened) or its catalog entry changed.\n\nUpsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the\nserver dispatches this with the full entry to record a newly opened\ncanvas, or to republish it after a trust/availability/incarnation change\nso subscribers following only the session channel stay in sync with\n{@link CanvasState}. Never client-dispatchable — canvases are admitted\nonly through the `openCanvas` command. A stale/out-of-order delivery\n(`canvas.revision` not strictly greater than the currently-recorded\nentry's revision) MUST be rejected (no-op) rather than overwrite a newer\nentry with older data.", + "properties": { + "type": { + "const": "session/canvasSet" + }, + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The canvas entry to add or update, matched by `resource`." + } + }, + "required": [ + "type", + "canvas" + ] + }, + "SessionCanvasRemovedAction": { + "type": "object", + "description": "A canvas was logically closed.\n\nRemove semantics keyed by `resource`: an unknown URI is a no-op. This\nrepresents durable membership removal, not a client hiding a local\ntab/view — see `closeCanvas`.", + "properties": { + "type": { + "const": "session/canvasRemoved" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}." + } + }, + "required": [ + "type", + "resource" + ] + }, "ChatTurnStartedAction": { "type": "object", "description": "A new message has been sent to the agent, and a new turn starts.\n\nA client is only allowed to send {@link MessageKind.User} messages.", @@ -10586,6 +11431,94 @@ "type" ] }, + "CanvasAvailabilityChangedAction": { + "type": "object", + "description": "Replaces the canvas's live resolution state.\n\nDispatched by the host on every availability transition: initial\nresolution after `openCanvas`, provider restart, reload, and failure.", + "properties": { + "type": { + "const": "canvas/availabilityChanged" + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "New {@link CanvasState.availability}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in. The reducer\nMUST reject (no-op) this action if `revision` is not strictly greater\nthan the canvas's current `revision` — this is how stale/out-of-order\ndeliveries are consistently rejected across every canvas action, not\njust this one." + } + }, + "required": [ + "type", + "availability", + "revision" + ] + }, + "CanvasTrustChangedAction": { + "type": "object", + "description": "Replaces the canvas's trust decision.\n\nDispatched by the host whenever the execution-trust decision for this\ncanvas's declared actions changes (e.g. a pending decision resolves, or an\nadministrator revokes a previously trusted source).", + "properties": { + "type": { + "const": "canvas/trustChanged" + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "New {@link CanvasState.trust}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "trust", + "revision" + ] + }, + "CanvasIncarnationChangedAction": { + "type": "object", + "description": "Records that the canvas's live endpoint was replaced by a fresh one for\nthe same logical instance (e.g. the owning provider restarted).\n\nThe host MUST dispatch {@link CanvasAvailabilityChangedAction} to\ntransition through `notLoaded`/`loading` around this change. Receivers\nMUST reject in-flight `invokeCanvasAction` replies and stale server-pushed\ncallbacks addressed to a superseded `incarnation` — because `incarnation`\nis opaque (see {@link CanvasIdentity.incarnation}), that rejection is\ndriven by the accompanying `revision` bump here, not by comparing\n`incarnation` values for order.", + "properties": { + "type": { + "const": "canvas/incarnationChanged" + }, + "incarnation": { + "type": "string", + "description": "New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "incarnation", + "revision" + ] + }, + "CanvasTitleChangedAction": { + "type": "object", + "description": "Replaces the canvas's display title.", + "properties": { + "type": { + "const": "canvas/titleChanged" + }, + "title": { + "type": "string", + "description": "New {@link CanvasState.title}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "title", + "revision" + ] + }, "ChatToolCallApprovedAction": { "type": "object", "description": "Client approves a pending tool call. The tool transitions to `running`.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 01271335..89f8c7c6 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -680,6 +680,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -1069,6 +1072,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted only via `openCanvas` — never\nimplied by a chat's existence or a client's earlier focus. Each entry's\n{@link CanvasIdentity.chat | `identity.chat`} identifies the exact\nbacking chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -5824,6 +5834,462 @@ "sessions" ] }, + "CanvasExtensionSource": { + "type": "object", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", + "properties": { + "kind": { + "const": "extension" + }, + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "extensionId" + ] + }, + "CanvasPackageSource": { + "type": "object", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", + "properties": { + "kind": { + "const": "package" + }, + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." + }, + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "sourceId", + "packageName" + ] + }, + "CanvasIdentityKey": { + "type": "object", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId" + ] + }, + "CanvasIdentity": { + "type": "object", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nagainst the same still-live endpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) never opens, materializes, or restarts\nanything; only `openCanvas` does.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient — MUST\nNOT be persisted, cached beyond the current read, or treated as a\nstable/durable identity. A host MAY embed short-lived, single-use\ncredentials in it; such credentials are never durable authority." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -6544,6 +7010,66 @@ } ], "description": "Discriminated lifecycle of an automation run." + }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index 097236df..ee08991b 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -424,6 +424,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -813,6 +816,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted only via `openCanvas` — never\nimplied by a chat's existence or a client's earlier focus. Each entry's\n{@link CanvasIdentity.chat | `identity.chat`} identifies the exact\nbacking chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -5568,6 +5578,462 @@ "sessions" ] }, + "CanvasExtensionSource": { + "type": "object", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", + "properties": { + "kind": { + "const": "extension" + }, + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "extensionId" + ] + }, + "CanvasPackageSource": { + "type": "object", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", + "properties": { + "kind": { + "const": "package" + }, + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." + }, + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "sourceId", + "packageName" + ] + }, + "CanvasIdentityKey": { + "type": "object", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId" + ] + }, + "CanvasIdentity": { + "type": "object", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nagainst the same still-live endpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) never opens, materializes, or restarts\nanything; only `openCanvas` does.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient — MUST\nNOT be persisted, cached beyond the current read, or treated as a\nstable/durable identity. A host MAY embed short-lived, single-use\ncredentials in it; such credentials are never durable authority." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -6113,6 +6579,54 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -6280,6 +6794,18 @@ ], "type": "string", "description": "Operations the host currently permits for an automation.\n\nThe list on {@link AutomationEntry.operations} is authoritative and may\nchange over time. Clients MUST NOT infer permission from capabilities alone:\ncapabilities describe what the host implementation can support, while\noperations describe what is allowed for this particular automation now." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." } } } diff --git a/scripts/find-protocol-sources.ts b/scripts/find-protocol-sources.ts index 79fd2bdc..457d026b 100644 --- a/scripts/find-protocol-sources.ts +++ b/scripts/find-protocol-sources.ts @@ -26,6 +26,7 @@ export const PROTOCOL_SOURCE_DIRS: readonly string[] = [ 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-canvas', ]; /** diff --git a/scripts/generate-action-origin.ts b/scripts/generate-action-origin.ts index b38eb2bb..e356a630 100644 --- a/scripts/generate-action-origin.ts +++ b/scripts/generate-action-origin.ts @@ -17,7 +17,7 @@ const GENERATED_HEADER = `// Generated from types/actions.ts — do not edit // Run \`npm run generate\` to regenerate. `; -type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; +type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun' | 'canvas'; interface ActionInfo { /** The interface name (e.g. 'RootAgentsChangedAction') */ @@ -157,6 +157,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { : category === 'Resource Watch Actions' ? 'resourceWatch' : category === 'Automation Actions' ? 'automation' : category === 'Automation Run Actions' ? 'automationRun' + : category === 'Canvas Actions' ? 'canvas' : 'session'; const isClientDispatchable = hasJsDocTag(node as any, 'clientDispatchable'); @@ -211,6 +212,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const resourceWatchActions = actions.filter(a => a.scope === 'resourceWatch'); const automationActions = actions.filter(a => a.scope === 'automation'); const automationRunActions = actions.filter(a => a.scope === 'automationRun'); + const canvasActions = actions.filter(a => a.scope === 'canvas'); const clientRootActions = rootActions.filter(a => a.isClientDispatchable); const serverRootActions = rootActions.filter(a => !a.isClientDispatchable); const clientSessionActions = sessionActions.filter(a => a.isClientDispatchable); @@ -229,6 +231,8 @@ export function generateActionOrigin(project: Project, outDir: string): void { const serverAutomationActions = automationActions.filter(a => !a.isClientDispatchable); const clientAutomationRunActions = automationRunActions.filter(a => a.isClientDispatchable); const serverAutomationRunActions = automationRunActions.filter(a => !a.isClientDispatchable); + const clientCanvasActions = canvasActions.filter(a => a.isClientDispatchable); + const serverCanvasActions = canvasActions.filter(a => !a.isClientDispatchable); const lines: string[] = [GENERATED_HEADER]; @@ -520,6 +524,34 @@ export function generateActionOrigin(project: Project, outDir: string): void { lines.push(`;`); lines.push(``); + // CanvasAction + lines.push(`/** Union of all canvas-scoped actions. */`); + lines.push(`export type CanvasAction =`); + for (const a of canvasActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of canvas actions that clients may dispatch. */`); + lines.push(`export type ClientCanvasAction =`); + if (clientCanvasActions.length === 0) { + lines.push(` never`); + } else { + for (const a of clientCanvasActions) { + lines.push(` | ${a.name}`); + } + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of canvas actions that only the server may produce. */`); + lines.push(`export type ServerCanvasAction =`); + for (const a of serverCanvasActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); // IS_CLIENT_DISPATCHABLE map lines.push(`// ─── Client-Dispatchable Map ─────────────────────────────────────────────────`); diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 20721221..ceffbd59 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -646,6 +646,7 @@ const STATE_ENUMS = [ 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; // `mutable: true` marks the STATE types the reducers mutate in place — these @@ -803,6 +804,24 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'AutomationCancelledRunLifecycle' }, { name: 'AutomationRunSummary' }, { name: 'AutomationRunState', mutable: true }, + { name: 'CanvasExtensionSource', omitDiscriminants: true }, + { name: 'CanvasPackageSource', omitDiscriminants: true }, + { name: 'CanvasIdentityKey' }, + { name: 'CanvasIdentity' }, + { name: 'CanvasTrustedState', omitDiscriminants: true }, + { name: 'CanvasPendingTrustState', omitDiscriminants: true }, + { name: 'CanvasBlockedTrustState', omitDiscriminants: true }, + { name: 'CanvasActionDeclaration' }, + { name: 'CanvasUnsupportedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasNotLoadedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasLoadingAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEmptyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasReadyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasFailedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEntry', mutable: true }, + { name: 'CanvasState', mutable: true }, + { name: 'CanvasTypeDeclaration' }, + { name: 'CanvasSourcePresentation' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1202,6 +1221,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { ], }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + doc: 'CanvasSource identifies the explicitly installed extension or package that declares a canvas type.', + variants: [ + { variantName: 'Extension', innerType: 'CanvasExtensionSource', wireValue: 'extension' }, + { variantName: 'Package', innerType: 'CanvasPackageSource', wireValue: 'package' }, + ], +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + doc: 'CanvasTrustState is the current trust decision governing whether a canvas\'s declared actions may execute.', + variants: [ + { variantName: 'Trusted', innerType: 'CanvasTrustedState', wireValue: 'trusted' }, + { variantName: 'Pending', innerType: 'CanvasPendingTrustState', wireValue: 'pending' }, + { variantName: 'Blocked', innerType: 'CanvasBlockedTrustState', wireValue: 'blocked' }, + ], +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + doc: 'CanvasAvailabilityState is the current live resolution state of a canvas.', + variants: [ + { variantName: 'Unsupported', innerType: 'CanvasUnsupportedAvailabilityState', wireValue: 'unsupported' }, + { variantName: 'NotLoaded', innerType: 'CanvasNotLoadedAvailabilityState', wireValue: 'notLoaded' }, + { variantName: 'Loading', innerType: 'CanvasLoadingAvailabilityState', wireValue: 'loading' }, + { variantName: 'Empty', innerType: 'CanvasEmptyAvailabilityState', wireValue: 'empty' }, + { variantName: 'Ready', innerType: 'CanvasReadyAvailabilityState', wireValue: 'ready' }, + { variantName: 'Failed', innerType: 'CanvasFailedAvailabilityState', wireValue: 'failed' }, + ], +}; + const CUSTOMIZATION_ENABLEMENT_UNION_CS = `/// A single explicit customization enablement decision. [JsonConverter(typeof(CustomizationEnablementConverter))] public sealed class CustomizationEnablement : AhpUnion @@ -1393,6 +1447,7 @@ function generateStateFile(project: Project): string { MCP_SERVER_STATUS_UNION, TOOL_CALL_CONTRIBUTOR_UNION, SESSION_INPUT_REQUEST_UNION, TERMINAL_LIFECYCLE_STATE_UNION, SESSION_ORIGIN_UNION, AUTOMATION_TRIGGER_UNION, AUTOMATION_RUN_ORIGIN_UNION, AUTOMATION_RUN_LIFECYCLE_UNION, + CANVAS_SOURCE_UNION, CANVAS_TRUST_STATE_UNION, CANVAS_AVAILABILITY_STATE_UNION, ]) { lines.push(generateDiscriminatedUnion(u)); lines.push(''); @@ -1529,6 +1584,12 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string { type: 'automationRun/sessionRemoved', variantName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', variantName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', variantName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', variantName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', variantName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', variantName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', variantName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', variantName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', variantName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, ]; function generateMergedToolCallConfirmedClass(): string { @@ -2081,6 +2142,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str { name: 'Implementation' }, { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, + { name: 'CanvasCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, { name: 'AutomationRunCancellationCapability' }, @@ -2125,6 +2187,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str { name: 'ListAutomationTriggerDefinitionsParams' }, { name: 'ListAutomationTriggerDefinitionsResult' }, { name: 'RunAutomationParams' }, { name: 'RunAutomationResult' }, { name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' }, + { name: 'ListCanvasTypesParams' }, { name: 'ListCanvasTypesResult' }, + { name: 'OpenCanvasParams' }, { name: 'OpenCanvasResult' }, + { name: 'ResolveCanvasSourceParams' }, { name: 'ResolveCanvasSourceResult' }, + { name: 'InvokeCanvasActionParams' }, { name: 'InvokeCanvasActionResult' }, + { name: 'RestartCanvasProviderParams' }, { name: 'CloseCanvasParams' }, ]; const CHAT_SOURCE_UNION: UnionConfig = { @@ -2560,6 +2627,7 @@ function checkExhaustiveness(project: Project): void { 'CustomizationLoadState', 'McpServerState', 'ToolCallContributor', 'SessionOrigin', 'TerminalLifecycleState', 'AutomationTrigger', 'AutomationRunOrigin', 'AutomationRunLifecycle', + 'CanvasSource', 'CanvasTrustState', 'CanvasAvailabilityState', 'SessionInputRequest', 'ToolCallConfirmationState', 'ToolCallRiskAssessment', 'ReconnectResult', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 71926ac2..4fcf813d 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -174,6 +174,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState' ) { @@ -728,6 +729,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [ @@ -883,6 +885,24 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'AutomationCancelledRunLifecycle' }, { name: 'AutomationRunSummary' }, { name: 'AutomationRunState' }, + { name: 'CanvasExtensionSource', omitDiscriminants: true }, + { name: 'CanvasPackageSource', omitDiscriminants: true }, + { name: 'CanvasIdentityKey' }, + { name: 'CanvasIdentity' }, + { name: 'CanvasTrustedState', omitDiscriminants: true }, + { name: 'CanvasPendingTrustState', omitDiscriminants: true }, + { name: 'CanvasBlockedTrustState', omitDiscriminants: true }, + { name: 'CanvasActionDeclaration' }, + { name: 'CanvasUnsupportedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasNotLoadedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasLoadingAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEmptyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasReadyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasFailedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEntry' }, + { name: 'CanvasState' }, + { name: 'CanvasTypeDeclaration' }, + { name: 'CanvasSourcePresentation' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1165,6 +1185,44 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { injectDiscriminantOnMarshal: true, }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + doc: 'CanvasSource identifies the explicitly installed extension or package that declares a canvas type.', + variants: [ + { variantName: 'Extension', innerType: 'CanvasExtensionSource', wireValue: 'extension' }, + { variantName: 'Package', innerType: 'CanvasPackageSource', wireValue: 'package' }, + ], + injectDiscriminantOnMarshal: true, +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + doc: 'CanvasTrustState is the current trust decision governing whether a canvas\'s declared actions may execute.', + variants: [ + { variantName: 'Trusted', innerType: 'CanvasTrustedState', wireValue: 'trusted' }, + { variantName: 'Pending', innerType: 'CanvasPendingTrustState', wireValue: 'pending' }, + { variantName: 'Blocked', innerType: 'CanvasBlockedTrustState', wireValue: 'blocked' }, + ], + injectDiscriminantOnMarshal: true, +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + doc: 'CanvasAvailabilityState is the current live resolution state of a canvas.', + variants: [ + { variantName: 'Unsupported', innerType: 'CanvasUnsupportedAvailabilityState', wireValue: 'unsupported' }, + { variantName: 'NotLoaded', innerType: 'CanvasNotLoadedAvailabilityState', wireValue: 'notLoaded' }, + { variantName: 'Loading', innerType: 'CanvasLoadingAvailabilityState', wireValue: 'loading' }, + { variantName: 'Empty', innerType: 'CanvasEmptyAvailabilityState', wireValue: 'empty' }, + { variantName: 'Ready', innerType: 'CanvasReadyAvailabilityState', wireValue: 'ready' }, + { variantName: 'Failed', innerType: 'CanvasFailedAvailabilityState', wireValue: 'failed' }, + ], + injectDiscriminantOnMarshal: true, +}; + function generateChatOriginGo(): string { return `// ChatOrigin describes how a chat came into existence. type ChatOrigin struct { @@ -1499,6 +1557,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateChatOriginGo()); lines.push(''); lines.push(generateSnapshotState()); @@ -1610,6 +1674,12 @@ const ACTION_VARIANTS: { { type: 'automationRun/sessionRemoved', variantName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', variantName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', variantName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', variantName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', variantName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', variantName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', variantName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', variantName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', variantName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, ]; function generateMergedChatToolCallConfirmedStruct(): string { @@ -1715,7 +1785,7 @@ const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [ { name: 'InitializeParams' }, { name: 'InitializeResult' }, - { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, + { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, { name: 'CanvasCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, { name: 'AutomationRunCancellationCapability' }, @@ -1752,6 +1822,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'ListAutomationTriggerDefinitionsParams' }, { name: 'ListAutomationTriggerDefinitionsResult' }, { name: 'RunAutomationParams' }, { name: 'RunAutomationResult' }, { name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' }, + { name: 'ListCanvasTypesParams' }, { name: 'ListCanvasTypesResult' }, + { name: 'OpenCanvasParams' }, { name: 'OpenCanvasResult' }, + { name: 'ResolveCanvasSourceParams' }, { name: 'ResolveCanvasSourceResult' }, + { name: 'InvokeCanvasActionParams' }, { name: 'InvokeCanvasActionResult' }, + { name: 'RestartCanvasProviderParams' }, { name: 'CloseCanvasParams' }, ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -2323,6 +2398,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', 'AutomationRunOrigin', 'AutomationRunLifecycle', + 'CanvasSource', + 'CanvasTrustState', + 'CanvasAvailabilityState', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index ea13b41e..0158663c 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -154,6 +154,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || @@ -980,6 +981,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; const STATE_STRUCTS = [ @@ -1048,6 +1050,13 @@ const STATE_STRUCTS = [ 'AutomationCompletedRunLifecycle', 'AutomationFailedRunLifecycle', 'AutomationCancelledRunLifecycle', 'AutomationRunSummary', 'AutomationRunState', + 'CanvasExtensionSource', 'CanvasPackageSource', 'CanvasIdentityKey', 'CanvasIdentity', + 'CanvasTrustedState', 'CanvasPendingTrustState', 'CanvasBlockedTrustState', + 'CanvasActionDeclaration', + 'CanvasUnsupportedAvailabilityState', 'CanvasNotLoadedAvailabilityState', + 'CanvasLoadingAvailabilityState', 'CanvasEmptyAvailabilityState', + 'CanvasReadyAvailabilityState', 'CanvasFailedAvailabilityState', + 'CanvasEntry', 'CanvasState', 'CanvasTypeDeclaration', 'CanvasSourcePresentation', ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1363,6 +1372,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { injectDiscriminantOnSerialize: true, }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + variants: [ + { caseName: 'Extension', structName: 'CanvasExtensionSource', discriminantValue: 'extension' }, + { caseName: 'Package', structName: 'CanvasPackageSource', discriminantValue: 'package' }, + ], + injectDiscriminantOnSerialize: true, +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + variants: [ + { caseName: 'Trusted', structName: 'CanvasTrustedState', discriminantValue: 'trusted' }, + { caseName: 'Pending', structName: 'CanvasPendingTrustState', discriminantValue: 'pending' }, + { caseName: 'Blocked', structName: 'CanvasBlockedTrustState', discriminantValue: 'blocked' }, + ], + injectDiscriminantOnSerialize: true, +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + variants: [ + { caseName: 'Unsupported', structName: 'CanvasUnsupportedAvailabilityState', discriminantValue: 'unsupported' }, + { caseName: 'NotLoaded', structName: 'CanvasNotLoadedAvailabilityState', discriminantValue: 'notLoaded' }, + { caseName: 'Loading', structName: 'CanvasLoadingAvailabilityState', discriminantValue: 'loading' }, + { caseName: 'Empty', structName: 'CanvasEmptyAvailabilityState', discriminantValue: 'empty' }, + { caseName: 'Ready', structName: 'CanvasReadyAvailabilityState', discriminantValue: 'ready' }, + { caseName: 'Failed', structName: 'CanvasFailedAvailabilityState', discriminantValue: 'failed' }, + ], + injectDiscriminantOnSerialize: true, +}; + function generateStateFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1454,6 +1498,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateToolResultContentUnion()); lines.push(''); lines.push(generateSnapshotState()); @@ -1561,6 +1611,12 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'automationRun/sessionRemoved', caseName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', caseName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', caseName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', caseName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', caseName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', caseName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', caseName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', caseName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', caseName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, ]; /** Merged data class for the approved/denied tool call confirmed action. */ @@ -1721,7 +1777,7 @@ const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding const COMMAND_STRUCTS = [ 'InitializeParams', 'InitializeResult', - 'ClientCapabilities', 'AutomationCapabilities', + 'ClientCapabilities', 'AutomationCapabilities', 'CanvasCapabilities', 'AutomationCreateCapability', 'AutomationScheduleCapabilities', 'AutomationRunCancellationCapability', @@ -1755,6 +1811,11 @@ const COMMAND_STRUCTS = [ 'ListAutomationTriggerDefinitionsParams', 'ListAutomationTriggerDefinitionsResult', 'RunAutomationParams', 'RunAutomationResult', 'FetchAutomationRunsParams', 'FetchAutomationRunsResult', + 'ListCanvasTypesParams', 'ListCanvasTypesResult', + 'OpenCanvasParams', 'OpenCanvasResult', + 'ResolveCanvasSourceParams', 'ResolveCanvasSourceResult', + 'InvokeCanvasActionParams', 'InvokeCanvasActionResult', + 'RestartCanvasProviderParams', 'CloseCanvasParams', ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -2366,6 +2427,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union 'AutomationRunOrigin', // AUTOMATION_RUN_ORIGIN_UNION discriminated union 'AutomationRunLifecycle', // AUTOMATION_RUN_LIFECYCLE_UNION discriminated union + 'CanvasSource', // CANVAS_SOURCE_UNION discriminated union + 'CanvasTrustState', // CANVAS_TRUST_STATE_UNION discriminated union + 'CanvasAvailabilityState', // CANVAS_AVAILABILITY_STATE_UNION discriminated union 'ForkChatSource', // generateFixedChatSourceBranchKotlin() 'SideChatSource', // generateFixedChatSourceBranchKotlin() 'ChangesetOperationTarget', // generateChangesetOperationTargetKotlin() diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 4e999bbc..02549e47 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -164,6 +164,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -768,6 +769,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; /** @@ -944,6 +946,24 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'AutomationCancelledRunLifecycle', omitDiscriminants: true }, { name: 'AutomationRunSummary' }, { name: 'AutomationRunState' }, + { name: 'CanvasExtensionSource', omitDiscriminants: true }, + { name: 'CanvasPackageSource', omitDiscriminants: true }, + { name: 'CanvasIdentityKey' }, + { name: 'CanvasIdentity' }, + { name: 'CanvasTrustedState', omitDiscriminants: true }, + { name: 'CanvasPendingTrustState', omitDiscriminants: true }, + { name: 'CanvasBlockedTrustState', omitDiscriminants: true }, + { name: 'CanvasActionDeclaration' }, + { name: 'CanvasUnsupportedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasNotLoadedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasLoadingAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEmptyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasReadyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasFailedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEntry' }, + { name: 'CanvasState' }, + { name: 'CanvasTypeDeclaration' }, + { name: 'CanvasSourcePresentation' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1227,6 +1247,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { ], }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + doc: 'Identifies the explicitly installed extension or package that declares a canvas type.', + variants: [ + { variantName: 'Extension', innerType: 'CanvasExtensionSource', wireValue: 'extension' }, + { variantName: 'Package', innerType: 'CanvasPackageSource', wireValue: 'package' }, + ], +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + doc: 'Current trust decision governing whether a canvas\'s declared actions may execute.', + variants: [ + { variantName: 'Trusted', innerType: 'CanvasTrustedState', wireValue: 'trusted' }, + { variantName: 'Pending', innerType: 'CanvasPendingTrustState', wireValue: 'pending' }, + { variantName: 'Blocked', innerType: 'CanvasBlockedTrustState', wireValue: 'blocked' }, + ], +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + doc: 'Current live resolution state of a canvas.', + variants: [ + { variantName: 'Unsupported', innerType: 'CanvasUnsupportedAvailabilityState', wireValue: 'unsupported' }, + { variantName: 'NotLoaded', innerType: 'CanvasNotLoadedAvailabilityState', wireValue: 'notLoaded' }, + { variantName: 'Loading', innerType: 'CanvasLoadingAvailabilityState', wireValue: 'loading' }, + { variantName: 'Empty', innerType: 'CanvasEmptyAvailabilityState', wireValue: 'empty' }, + { variantName: 'Ready', innerType: 'CanvasReadyAvailabilityState', wireValue: 'ready' }, + { variantName: 'Failed', innerType: 'CanvasFailedAvailabilityState', wireValue: 'failed' }, + ], +}; + function generateChatOrigin(project: Project): string { const originKind = findEnum(project, 'ChatOriginKind'); if (!originKind) throw new Error('ChatOriginKind enum not found'); @@ -1401,6 +1456,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateSnapshotState()); lines.push(''); @@ -1513,6 +1574,12 @@ const ACTION_VARIANTS: { { type: 'automationRun/sessionRemoved', variantName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', variantName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', variantName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', variantName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', variantName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', variantName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction', boxed: true }, + { type: 'canvas/trustChanged', variantName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', variantName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', variantName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, ]; function generateMergedToolCallConfirmedStruct(scope: 'Session' | 'Chat' = 'Session'): string { @@ -1698,7 +1765,7 @@ const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: string }[] = [ { name: 'InitializeParams' }, { name: 'InitializeResult' }, - { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, + { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, { name: 'CanvasCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, { name: 'AutomationRunCancellationCapability' }, @@ -1736,6 +1803,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'ListAutomationTriggerDefinitionsParams' }, { name: 'ListAutomationTriggerDefinitionsResult' }, { name: 'RunAutomationParams' }, { name: 'RunAutomationResult' }, { name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' }, + { name: 'ListCanvasTypesParams' }, { name: 'ListCanvasTypesResult' }, + { name: 'OpenCanvasParams' }, { name: 'OpenCanvasResult' }, + { name: 'ResolveCanvasSourceParams' }, { name: 'ResolveCanvasSourceResult' }, + { name: 'InvokeCanvasActionParams' }, { name: 'InvokeCanvasActionResult' }, + { name: 'RestartCanvasProviderParams' }, { name: 'CloseCanvasParams' }, ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -2230,6 +2302,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', 'AutomationRunOrigin', 'AutomationRunLifecycle', + 'CanvasSource', + 'CanvasTrustState', + 'CanvasAvailabilityState', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 89fa9bcd..f29c35d8 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -120,6 +120,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -685,6 +686,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; const STATE_STRUCTS = [ @@ -753,6 +755,13 @@ const STATE_STRUCTS = [ 'AutomationCompletedRunLifecycle', 'AutomationFailedRunLifecycle', 'AutomationCancelledRunLifecycle', 'AutomationRunSummary', 'AutomationRunState', + 'CanvasExtensionSource', 'CanvasPackageSource', 'CanvasIdentityKey', 'CanvasIdentity', + 'CanvasTrustedState', 'CanvasPendingTrustState', 'CanvasBlockedTrustState', + 'CanvasActionDeclaration', + 'CanvasUnsupportedAvailabilityState', 'CanvasNotLoadedAvailabilityState', + 'CanvasLoadingAvailabilityState', 'CanvasEmptyAvailabilityState', + 'CanvasReadyAvailabilityState', 'CanvasFailedAvailabilityState', + 'CanvasEntry', 'CanvasState', 'CanvasTypeDeclaration', 'CanvasSourcePresentation', ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1267,6 +1276,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { injectDiscriminantOnEncode: true, }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + variants: [ + { caseName: 'extension', structName: 'CanvasExtensionSource', discriminantValue: 'extension' }, + { caseName: 'package', structName: 'CanvasPackageSource', discriminantValue: 'package' }, + ], + injectDiscriminantOnEncode: true, +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + variants: [ + { caseName: 'trusted', structName: 'CanvasTrustedState', discriminantValue: 'trusted' }, + { caseName: 'pending', structName: 'CanvasPendingTrustState', discriminantValue: 'pending' }, + { caseName: 'blocked', structName: 'CanvasBlockedTrustState', discriminantValue: 'blocked' }, + ], + injectDiscriminantOnEncode: true, +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + variants: [ + { caseName: 'unsupported', structName: 'CanvasUnsupportedAvailabilityState', discriminantValue: 'unsupported' }, + { caseName: 'notLoaded', structName: 'CanvasNotLoadedAvailabilityState', discriminantValue: 'notLoaded' }, + { caseName: 'loading', structName: 'CanvasLoadingAvailabilityState', discriminantValue: 'loading' }, + { caseName: 'empty', structName: 'CanvasEmptyAvailabilityState', discriminantValue: 'empty' }, + { caseName: 'ready', structName: 'CanvasReadyAvailabilityState', discriminantValue: 'ready' }, + { caseName: 'failed', structName: 'CanvasFailedAvailabilityState', discriminantValue: 'failed' }, + ], + injectDiscriminantOnEncode: true, +}; + function generateStateFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1350,6 +1394,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateToolResultContentUnion()); lines.push(''); lines.push(generateSnapshotState()); @@ -1458,6 +1508,12 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'automationRun/sessionRemoved', caseName: 'automationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', caseName: 'automationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', caseName: 'automationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', caseName: 'sessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', caseName: 'sessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', caseName: 'canvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', caseName: 'canvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', caseName: 'canvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', caseName: 'canvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, ]; /** Merged struct for the approved/denied tool call confirmed action */ @@ -1627,7 +1683,7 @@ function generateActionsFile(project: Project): string { const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; const COMMAND_STRUCTS = [ - 'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'AutomationCapabilities', + 'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'AutomationCapabilities', 'CanvasCapabilities', 'AutomationCreateCapability', 'AutomationScheduleCapabilities', 'AutomationRunCancellationCapability', @@ -1661,6 +1717,11 @@ const COMMAND_STRUCTS = [ 'ListAutomationTriggerDefinitionsParams', 'ListAutomationTriggerDefinitionsResult', 'RunAutomationParams', 'RunAutomationResult', 'FetchAutomationRunsParams', 'FetchAutomationRunsResult', + 'ListCanvasTypesParams', 'ListCanvasTypesResult', + 'OpenCanvasParams', 'OpenCanvasResult', + 'ResolveCanvasSourceParams', 'ResolveCanvasSourceResult', + 'InvokeCanvasActionParams', 'InvokeCanvasActionResult', + 'RestartCanvasProviderParams', 'CloseCanvasParams', ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -2383,6 +2444,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union 'AutomationRunOrigin', // AUTOMATION_RUN_ORIGIN_UNION discriminated union 'AutomationRunLifecycle', // AUTOMATION_RUN_LIFECYCLE_UNION discriminated union + 'CanvasSource', // CANVAS_SOURCE_UNION discriminated union + 'CanvasTrustState', // CANVAS_TRUST_STATE_UNION discriminated union + 'CanvasAvailabilityState', // CANVAS_AVAILABILITY_STATE_UNION discriminated union 'ForkChatSource', // generateFixedChatSourceBranchSwift() 'SideChatSource', // generateFixedChatSourceBranchSwift() 'ChangesetOperationTarget', // TS discriminated union; consumers should add a Swift case-iterable enum diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index b3686f61..8abd0709 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -35,6 +35,8 @@ import type { SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction, + SessionCanvasSetAction, + SessionCanvasRemovedAction, ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, @@ -99,6 +101,10 @@ import type { AutomationRunSessionRemovedAction, AutomationRunPrimarySessionChangedAction, AutomationRunCancelRequestedAction, + CanvasAvailabilityChangedAction, + CanvasTrustChangedAction, + CanvasIncarnationChangedAction, + CanvasTitleChangedAction, } from './actions.js'; import { ActionType } from './actions.js'; @@ -155,6 +161,8 @@ export type SessionAction = | SessionChangesetsChangedAction | SessionConfigChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction ; /** Union of session actions that clients may dispatch. */ @@ -191,6 +199,8 @@ export type ServerSessionAction = | SessionActivityChangedAction | SessionChangesetsChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction ; /** Union of all chat-scoped actions. */ @@ -407,6 +417,27 @@ export type ServerAutomationRunAction = | AutomationRunPrimarySessionChangedAction ; +/** Union of all canvas-scoped actions. */ +export type CanvasAction = + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction +; + +/** Union of canvas actions that clients may dispatch. */ +export type ClientCanvasAction = + never +; + +/** Union of canvas actions that only the server may produce. */ +export type ServerCanvasAction = + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction +; + // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** @@ -446,6 +477,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionChangesetsChanged]: false, [ActionType.SessionConfigChanged]: true, [ActionType.SessionMetaChanged]: false, + [ActionType.SessionCanvasSet]: false, + [ActionType.SessionCanvasRemoved]: false, [ActionType.ChatTurnStarted]: true, [ActionType.ChatDelta]: false, [ActionType.ChatResponsePart]: false, @@ -510,4 +543,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.AutomationRunSessionRemoved]: false, [ActionType.AutomationRunPrimarySessionChanged]: false, [ActionType.AutomationRunCancelRequested]: true, + [ActionType.CanvasAvailabilityChanged]: false, + [ActionType.CanvasTrustChanged]: false, + [ActionType.CanvasIncarnationChanged]: false, + [ActionType.CanvasTitleChanged]: false, }; diff --git a/types/actions.ts b/types/actions.ts index 97ec41dc..292f37e0 100644 --- a/types/actions.ts +++ b/types/actions.ts @@ -17,3 +17,4 @@ export * from './channels-annotations/actions.js'; export * from './channels-resource-watch/actions.js'; export * from './channels-automation/actions.js'; export * from './channels-automation-run/actions.js'; +export * from './channels-canvas/actions.js'; diff --git a/types/channels-canvas.test.ts b/types/channels-canvas.test.ts new file mode 100644 index 00000000..937dff68 --- /dev/null +++ b/types/channels-canvas.test.ts @@ -0,0 +1,63 @@ +/** + * Boundary tests for the canvas channel's declared, enforceable size/depth + * limits (`CANVAS_SCHEMA_MAX_PROPERTIES`, `CANVAS_SCHEMA_MAX_DEPTH`, and the + * `isCanvasSchemaWithinLimits` helper hosts use to enforce them). These are + * protocol-mandated ceilings, not measured workload numbers — see + * `types/channels-canvas/state.ts` for the full contract. + * + * Run: npx tsx --test types/channels-canvas.test.ts + */ + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + CANVAS_SCHEMA_MAX_PROPERTIES, + CANVAS_SCHEMA_MAX_DEPTH, + isCanvasSchemaWithinLimits, +} from './channels-canvas/state.js'; + +/** Builds an object schema with `count` trivial top-level properties. */ +function schemaWithProperties(count: number): { properties: Record } { + const properties: Record = {}; + for (let i = 0; i < count; i++) { + properties[`p${i}`] = { type: 'string' }; + } + return { properties }; +} + +/** Builds a schema nested `depth` levels deep (depth 1 = no nested `properties`). */ +function schemaWithDepth(depth: number): { properties: Record } { + let inner: { properties?: Record } = {}; + for (let level = depth; level > 1; level--) { + inner = { properties: { child: inner } }; + } + return { properties: { child: inner } }; +} + +describe('isCanvasSchemaWithinLimits', () => { + it('accepts a schema with no properties', () => { + assert.equal(isCanvasSchemaWithinLimits({}), true); + }); + + it('accepts a schema at exactly CANVAS_SCHEMA_MAX_PROPERTIES', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithProperties(CANVAS_SCHEMA_MAX_PROPERTIES)), true); + }); + + it('rejects a schema exceeding CANVAS_SCHEMA_MAX_PROPERTIES by one', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithProperties(CANVAS_SCHEMA_MAX_PROPERTIES + 1)), false); + }); + + it('accepts a schema nested at exactly CANVAS_SCHEMA_MAX_DEPTH', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithDepth(CANVAS_SCHEMA_MAX_DEPTH)), true); + }); + + it('rejects a schema nested one level past CANVAS_SCHEMA_MAX_DEPTH', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithDepth(CANVAS_SCHEMA_MAX_DEPTH + 1)), false); + }); + + it('rejects a schema whose properties exceed the bound only at a nested level', () => { + const nested = schemaWithProperties(CANVAS_SCHEMA_MAX_PROPERTIES + 1); + assert.equal(isCanvasSchemaWithinLimits({ properties: { child: nested } }), false); + }); +}); diff --git a/types/channels-canvas/actions.ts b/types/channels-canvas/actions.ts new file mode 100644 index 00000000..5d24d596 --- /dev/null +++ b/types/channels-canvas/actions.ts @@ -0,0 +1,93 @@ +/** + * Canvas Channel Actions — Mutations of an `ahp-canvas:` channel's state. + * + * All actions here are server-dispatched: canvas live state reflects + * authoritative resolution outcomes, not client-optimistic writes. Clients + * request effects through the `openCanvas`, `invokeCanvasAction`, + * `restartCanvasProvider`, and `closeCanvas` commands instead. + * + * @module channels-canvas/actions + */ + +import { ActionType } from '../common/actions.js'; +import type { CanvasAvailabilityState, CanvasTrustState } from './state.js'; + +// ─── Canvas Actions ────────────────────────────────────────────────────────── + +/** + * Replaces the canvas's live resolution state. + * + * Dispatched by the host on every availability transition: initial + * resolution after `openCanvas`, provider restart, reload, and failure. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasAvailabilityChangedAction { + type: ActionType.CanvasAvailabilityChanged; + /** New {@link CanvasState.availability}. */ + availability: CanvasAvailabilityState; + /** + * The {@link CanvasState.revision} this action results in. The reducer + * MUST reject (no-op) this action if `revision` is not strictly greater + * than the canvas's current `revision` — this is how stale/out-of-order + * deliveries are consistently rejected across every canvas action, not + * just this one. + */ + revision: number; +} + +/** + * Replaces the canvas's trust decision. + * + * Dispatched by the host whenever the execution-trust decision for this + * canvas's declared actions changes (e.g. a pending decision resolves, or an + * administrator revokes a previously trusted source). + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasTrustChangedAction { + type: ActionType.CanvasTrustChanged; + /** New {@link CanvasState.trust}. */ + trust: CanvasTrustState; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Records that the canvas's live endpoint was replaced by a fresh one for + * the same logical instance (e.g. the owning provider restarted). + * + * The host MUST dispatch {@link CanvasAvailabilityChangedAction} to + * transition through `notLoaded`/`loading` around this change. Receivers + * MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed + * callbacks addressed to a superseded `incarnation` — because `incarnation` + * is opaque (see {@link CanvasIdentity.incarnation}), that rejection is + * driven by the accompanying `revision` bump here, not by comparing + * `incarnation` values for order. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasIncarnationChangedAction { + type: ActionType.CanvasIncarnationChanged; + /** New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. */ + incarnation: string; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Replaces the canvas's display title. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasTitleChangedAction { + type: ActionType.CanvasTitleChanged; + /** New {@link CanvasState.title}. */ + title: string; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} diff --git a/types/channels-canvas/commands.ts b/types/channels-canvas/commands.ts new file mode 100644 index 00000000..7cc303ec --- /dev/null +++ b/types/channels-canvas/commands.ts @@ -0,0 +1,332 @@ +/** + * Canvas Channel Commands — `listCanvasTypes`, `openCanvas`, + * `resolveCanvasSource`, `invokeCanvasAction`, `restartCanvasProvider`, and + * `closeCanvas`. + * + * @module channels-canvas/commands + */ + +import type { URI, Icon } from '../common/state.js'; +import type { BaseParams, PaginatedParams, PaginatedResult } from '../common/commands.js'; +import type { + CanvasAvailabilityStatus, + CanvasEntry, + CanvasIdentityKey, + CanvasSourcePresentation, + CanvasTypeDeclaration, +} from './state.js'; + +// ─── listCanvasTypes ───────────────────────────────────────────────────────── + +/** + * Discovers canvas TYPES currently available to open for one exact backing + * chat. + * + * This is a **pure read/browse** operation: it MUST NOT open, materialize, + * or otherwise admit any canvas — see `openCanvas` for that. It is + * unrelated to {@link SessionState.canvases}, which reflects durable + * membership of already-opened canvas INSTANCES, not the set of canvas + * TYPES a host/extension could open; do not confuse the two. + * + * @category Commands + * @method listCanvasTypes + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ListCanvasTypesParams extends BaseParams, PaginatedParams { + /** The exact backing chat to discover available canvas types for. */ + channel: URI; +} + +/** + * Available canvas types for the requested chat. + * + * @category Commands + */ +export interface ListCanvasTypesResult extends PaginatedResult { + /** Discovered canvas type declarations. */ + types: CanvasTypeDeclaration[]; +} + +// ─── openCanvas ────────────────────────────────────────────────────────────── + +/** + * Explicitly opens (admits) a canvas, associating it with the owning chat + * given by `identity.chat` at the moment of the call — never with whichever + * chat later happens to have focus. + * + * This is a read-write admission, not a resolve: unlike `subscribe` (which + * only reads current state), `openCanvas` is the operation that creates + * durable membership. There is no implicit open — a client MUST call this + * before a canvas appears in {@link SessionState.canvases}. Once admitted, + * clients read and follow live state by `subscribe`-ing to the returned + * `canvas.resource`, and resolve the current live endpoint via + * `resolveCanvasSource`; neither read itself opens, resumes, or restarts + * anything. + * + * **Logical identity is always singular.** The same {@link CanvasIdentityKey} + * (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same + * `canvas` resource URI and the same {@link SessionState.canvases} catalog + * entry, no matter how many times `openCanvas` is called for it — the server + * MUST return that existing entry's `resource` rather than mint a second + * one. A client-supplied `canvas` URI is honored only on the call that first + * establishes the identity; on a later call for an already-recorded + * identity the server MUST ignore the supplied `canvas` value and return the + * existing resource instead. + * + * **Idempotency is scoped to `requestId`, not identity.** Retrying with the + * exact same `requestId` and byte-for-byte identical params from the same + * authenticated connection MUST return the original result without + * repeating any side effect, within a bounded live window (the server is + * not required to remember it forever). Reusing the same `requestId` with + * any different parameter value MUST be rejected with `Conflict` + * (`-32011`) — mint a new `requestId` for a new logical call. A genuinely + * NEW `requestId` for an already-open identity MAY be effectful (e.g. + * updating `title`/`icon`, or causing the provider to re-run its own + * open-time initialization with new `input`) — this mirrors the pinned + * SDK's own repeated-open behavior and does not create a second logical + * identity. There is no exactly-once-across-crash guarantee: a lost reply + * is indeterminate, and clients MUST NOT automatically replay `openCanvas` + * — reconnect and read `SessionState.canvases` / `resolveCanvasSource` + * instead to determine the actual outcome. + * + * @category Commands + * @method openCanvas + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface OpenCanvasParams extends BaseParams { + /** Session URI that will list the opened canvas in `SessionState.canvases`. */ + channel: URI; + /** Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. */ + canvas: URI; + /** Logical identity to open or re-admit. */ + identity: CanvasIdentityKey; + /** Initial (or updated, on a later effectful call) display title. */ + title: string; + /** Initial (or updated) display icon. */ + icon?: Icon; + /** + * Bounded JSON input for this open call (e.g. seed parameters the + * provider uses to initialize the canvas), opaque to the protocol. See + * {@link CanvasTypeDeclaration.openInputSchema} / + * `openInputSchemaRef` for the expected shape. The JSON-serialized value + * MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + */ + input?: unknown; + /** + * Durable client-generated idempotency key bounding retry deduplication + * for this call within a live window; see the idempotency rules above. + * MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; +} + +/** + * Result identifying the existing or newly opened canvas. + * + * @category Commands + */ +export interface OpenCanvasResult { + /** The catalog entry for the opened (or already-open) canvas. */ + canvas: CanvasEntry; +} + +// ─── resolveCanvasSource ───────────────────────────────────────────────────── + +/** + * Pure, read-only read of a canvas's current live-resolution state and, + * when currently live, a transient endpoint presentation. + * + * This MUST NOT create, resume, reopen, or restart a provider. If the + * canvas does not currently have a live endpoint, `source` is absent and + * `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`) — + * call `restartCanvasProvider` (an explicitly effectful operation) to + * attempt recovery instead. A client-local page reload (re-navigating the + * client's own rendering surface to the same still-live `source.url`) + * needs no dedicated command at all; calling `resolveCanvasSource` again is + * also how a client retries resolving a currently-unavailable source + * without restarting anything. + * + * @category Commands + * @method resolveCanvasSource + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ResolveCanvasSourceParams extends BaseParams { + /** The canvas URI (an already-opened canvas's `resource`). */ + channel: URI; +} + +/** + * The canvas's current live-resolution state as of this read. + * + * @category Commands + */ +export interface ResolveCanvasSourceResult { + /** Current {@link CanvasEntry.availability}. */ + availability: CanvasAvailabilityStatus; + /** Current {@link CanvasIdentity.incarnation}. */ + incarnation: string; + /** Current {@link CanvasEntry.revision}. */ + revision: number; + /** Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. */ + source?: CanvasSourcePresentation; +} + +// ─── invokeCanvasAction ────────────────────────────────────────────────────── + +/** + * Invokes one of a canvas's currently declared actions exactly once. + * + * The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's + * current trust is not `trusted`, and with `NotFound` (`-32008`) if + * `actionId` does not match a currently declared action. `incarnation` is + * REQUIRED — omitting stale-generation protection on an effectful call is + * not allowed. If it does not match the canvas's current + * {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` + * (`-32011`) rather than route the call to a superseded endpoint. + * + * The result is the provider's raw reply and is never persisted into + * `CanvasState` — large or provider-specific payloads stay off the durable + * state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be + * represented out of band instead of being returned inline. Any resulting + * state changes (e.g. a subsequent availability transition) flow back + * separately through the normal `canvas/*` action stream on the canvas's + * own channel. + * + * A lost reply (e.g. a dropped connection after the provider already ran + * the handler) is **indeterminate**: clients MUST NOT automatically replay + * `invokeCanvasAction` on reconnect. Instead, reconnect and read the + * canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and + * decide from observed `revision`/`incarnation` and any provider-visible + * side effect whether to surface the ambiguity to the user, rather than + * assuming success or failure. + * + * @category Commands + * @method invokeCanvasAction + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface InvokeCanvasActionParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. */ + actionId: string; + /** + * Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + * if any. The JSON-serialized value MUST NOT exceed + * `CANVAS_INPUT_MAX_LENGTH`. + */ + input?: unknown; + /** + * Expected {@link CanvasIdentity.incarnation}. Required — see above. The + * server MUST reject the call with `Conflict` if the canvas's live + * endpoint has since been superseded, rather than deliver the call to it. + */ + incarnation: string; + /** + * Durable client-generated idempotency key bounding retry + * deduplication for this invocation within a live window. The server is + * not required to guarantee exactly-once execution across a crash. MUST + * NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; +} + +/** + * Result of invoking a declared canvas action. + * + * @category Commands + */ +export interface InvokeCanvasActionResult { + /** The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. */ + result: unknown; +} + +// ─── restartCanvasProvider ─────────────────────────────────────────────────── + +/** + * Explicitly restarts the provider/chat-scoped runtime backing this canvas: + * retires the current live endpoint and establishes a fresh one for the + * same logical instance. + * + * This is the **only** operation that intentionally causes an + * {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` + * (read-only source resolution / client-local page reload) MUST NEVER + * trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} + * (transitioning through `notLoaded`/`loading`) and then + * {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart + * never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or + * restore any prior in-flight effect. + * + * `incarnation` is REQUIRED: the server MUST reject with `Conflict` + * (`-32011`) if it does not match the canvas's current + * {@link CanvasIdentity.incarnation}, so a caller cannot restart a + * generation it never observed (e.g. after racing a concurrent restart). A + * lost reply is indeterminate; clients MUST NOT automatically replay this + * command — reconnect and compare the canvas's current `incarnation` + * instead. + * + * @category Commands + * @method restartCanvasProvider + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface RestartCanvasProviderParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; + /** Expected current {@link CanvasIdentity.incarnation}; required — see above. */ + incarnation: string; +} + +// ─── closeCanvas ───────────────────────────────────────────────────────────── + +/** + * Logically closes a canvas: removes its durable membership from + * `SessionState.canvases` and disposes matching views. + * + * This is distinct from a client merely hiding a local tab or view, which is + * presentation-only and MUST NOT dispatch this command. There is no + * advertised model tool for this operation — it is invoked only by + * UI/RPC callers. + * + * `revision` is REQUIRED: the server MUST reject with `Conflict` + * (`-32011`) if it does not match the canvas's current + * {@link CanvasEntry.revision}, so a caller cannot close membership state it + * never actually observed. If no matching entry exists (e.g. already + * closed), the server MUST treat this as a successful no-op rather than an + * error — the `revision` precondition only applies when an entry still + * exists. A lost reply is indeterminate; clients MUST NOT automatically + * replay this command — reconnect and check `SessionState.canvases` + * instead. + * + * @category Commands + * @method closeCanvas + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface CloseCanvasParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; + /** Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. */ + revision: number; +} diff --git a/types/channels-canvas/reducer.ts b/types/channels-canvas/reducer.ts new file mode 100644 index 00000000..7ca95527 --- /dev/null +++ b/types/channels-canvas/reducer.ts @@ -0,0 +1,51 @@ +/** + * Canvas Channel Reducer — Pure reducer for `CanvasState`. + * + * @module channels-canvas/reducer + */ + +import { ActionType } from '../common/actions.js'; +import type { CanvasAction } from '../action-origin.generated.js'; +import type { CanvasState } from './state.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; + +/** + * Pure reducer for canvas state. Handles all {@link CanvasAction} variants. + * + * Every variant carries the `revision` it results in. This reducer rejects + * (no-ops) any action whose `revision` is not strictly greater than the + * canvas's current `revision`, so a stale or out-of-order delivery can never + * overwrite newer state — including a hypothetical stale + * `canvas/incarnationChanged` reverting `identity.incarnation` to a + * superseded value. Applying an action always sets `state.revision` to the + * action's asserted `revision` (never a reducer-computed increment), keeping + * the contract consistent across all four action types. + */ +export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (msg: string) => void): CanvasState { + switch (action.type) { + case ActionType.CanvasAvailabilityChanged: + if (action.revision <= state.revision) return state; + return { ...state, availability: action.availability, revision: action.revision }; + + case ActionType.CanvasTrustChanged: + if (action.revision <= state.revision) return state; + return { ...state, trust: action.trust, revision: action.revision }; + + case ActionType.CanvasIncarnationChanged: + if (action.revision <= state.revision) return state; + return { + ...state, + identity: { ...state.identity, incarnation: action.incarnation }, + revision: action.revision, + }; + + case ActionType.CanvasTitleChanged: + if (action.revision <= state.revision) return state; + return { ...state, title: action.title, revision: action.revision }; + + default: + softAssertNever(action, log); + return state; + } +} + diff --git a/types/channels-canvas/state.ts b/types/channels-canvas/state.ts new file mode 100644 index 00000000..a2a59bfd --- /dev/null +++ b/types/channels-canvas/state.ts @@ -0,0 +1,598 @@ +/** + * Canvas State Types — Local canvas identity and lifecycle exposed on + * `ahp-canvas:` channels. + * + * A "canvas" is a provider-rendered surface (e.g. a local extension or + * installed package's custom view) associated with one exact backing chat. + * AHP models the canvas's identity, durable membership, admission/trust and + * live availability state machine, and its declared actions — never the + * rendering transport, page bytes, or a provider-specific SDK type. Hosts + * remain authoritative for how a canvas is actually served; this channel + * only carries what a client needs to discover, admit, and interact with one. + * + * Stability: 1.0 - Early development + * + * @module channels-canvas/state + */ + +import type { ErrorInfo, Icon, URI } from '../common/state.js'; + +// ─── Canvas Identity ───────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasSource} — what kind of package originates a + * canvas type. + * + * @category Canvas Identity + * @nonexhaustive + */ +export const enum CanvasSourceKind { + /** An explicitly installed host extension. */ + Extension = 'extension', + /** An explicitly installed package (not a host extension). */ + Package = 'package', +} + +/** + * A canvas type provided by an installed host extension. + * + * `extensionId` is the identity-bearing field for comparison purposes (see + * {@link CanvasIdentityKey}). `version` is display/informational metadata + * only — it MUST NOT be treated as identity-bearing (two `CanvasSource` + * values that differ only in `version` are the same source). + * + * @category Canvas Identity + */ +export interface CanvasExtensionSource { + kind: CanvasSourceKind.Extension; + /** + * Stable extension identifier (host-defined format, e.g. `publisher.name`). + * MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + extensionId: string; + /** Installed extension version, when known. Metadata only — not identity-bearing. */ + version?: string; +} + +/** + * A canvas type provided by an installed package that is not a host + * extension (e.g. a workspace-declared runtime package). + * + * `sourceId` — not `packageName` — is the identity-bearing field: the same + * declared package name MAY be installed in more than one scope (e.g. a + * workspace-local copy and a globally-installed copy, or two different + * registries), and each such installation is a distinct source with its own + * `sourceId`. `packageName` and `version` are display/informational metadata + * only and MUST NOT be treated as identity-bearing. + * + * @category Canvas Identity + */ +export interface CanvasPackageSource { + kind: CanvasSourceKind.Package; + /** + * Stable, host- or package-manager-assigned unique identifier for this + * specific installed package instance/scope (opaque format). This is the + * identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + sourceId: string; + /** Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. */ + packageName: string; + /** Installed package version, when known. Metadata only — not identity-bearing. */ + version?: string; +} + +/** + * Identifies the explicitly installed extension or package that declares a + * canvas type. This is provenance for admission and display; it is not a + * grant of execution trust by itself — see {@link CanvasTrustStatus}. + * + * @category Canvas Identity + */ +export type CanvasSource = CanvasExtensionSource | CanvasPackageSource; + +/** + * The logical identity of a canvas, excluding the host-assigned + * {@link CanvasIdentity.incarnation | `incarnation`}. + * + * Two canvases are the same logical canvas iff `chat`, `canvasType`, + * `instanceId`, and `source`'s **identity-bearing** fields are all equal: + * `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` + * plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and + * `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into + * this comparison. Clients MUST NOT treat + * {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — + * it is only unique within the scope of `(chat, source, canvasType)`. + * + * @category Canvas Identity + */ +export interface CanvasIdentityKey { + /** + * The exact backing chat this canvas belongs to. A canvas is never + * re-associated with a different chat; opening a new one for another chat + * creates a distinct canvas. + */ + chat: URI; + /** The extension or package that declares this canvas's type. */ + source: CanvasSource; + /** + * Provider-declared canvas type (host/provider-defined format). MUST NOT + * exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + canvasType: string; + /** + * Provider-chosen stable identifier for this canvas instance, scoped to + * `(chat, source, canvasType)`. Stable across reloads and host/window + * restarts for the same logical canvas. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + instanceId: string; +} + +/** + * Full identity of a canvas, including the host-assigned + * {@link CanvasIdentity.incarnation | `incarnation`}. + * + * @category Canvas Identity + */ +export interface CanvasIdentity extends CanvasIdentityKey { + /** + * Opaque, host-generated token identifying the current generation of this + * canvas's live endpoint. The host mints a fresh token whenever a provider + * restart retires the previous live endpoint and establishes a new one for + * the same logical instance (see {@link CanvasIncarnationChangedAction | + * `canvas/incarnationChanged`}); it is not changed by a plain page reload + * against the same still-live endpoint. + * + * `incarnation` is **opaque**: clients and hosts MUST compare it only for + * equality, never parse it, sort it, or perform arithmetic on it (e.g. it + * is not guaranteed to be numeric or monotonically increasing). The host + * MUST NOT reuse a token for this logical identity once it has been + * superseded, including across a host/process restart — if the host + * cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + * or timestamp-derived) that make accidental reuse practically + * impossible, rather than a small resettable counter. + * + * Clients and hosts use `incarnation` to reject stale callbacks and + * in-flight effects addressed to a superseded endpoint. + */ + incarnation: string; +} + +// ─── Limits ────────────────────────────────────────────────────────────────── + +/** + * Maximum UTF-16 code units in a `requestId` (`openCanvas`, + * `invokeCanvasAction`, `restartCanvasProvider`, `closeCanvas`). Hosts MUST + * reject a longer value with `InvalidParams` (`-32602`) rather than + * truncate it. + * + * @category Canvas Limits + */ +export const CANVAS_REQUEST_ID_MAX_LENGTH = 256; + +/** + * Maximum UTF-16 code units in any single identity-bearing string field: + * {@link CanvasIdentityKey.canvasType}, {@link CanvasIdentityKey.instanceId}, + * {@link CanvasExtensionSource.extensionId}, or + * {@link CanvasPackageSource.sourceId}. Hosts MUST reject a longer value + * with `InvalidParams` (`-32602`) rather than truncate it. + * + * @category Canvas Limits + */ +export const CANVAS_IDENTITY_FIELD_MAX_LENGTH = 256; + +/** + * Maximum number of top-level `properties` entries an inline JSON Schema + * (`CanvasActionDeclaration.inputSchema` / + * `CanvasTypeDeclaration.openInputSchema`) may declare at any single nesting + * level. A schema that would exceed this MUST instead be represented via + * `inputSchemaRef` / `openInputSchemaRef`. + * + * @category Canvas Limits + */ +export const CANVAS_SCHEMA_MAX_PROPERTIES = 64; + +/** + * Maximum nesting depth of an inline JSON Schema + * (`CanvasActionDeclaration.inputSchema` / + * `CanvasTypeDeclaration.openInputSchema`), counting the root object as + * depth `1`. A schema that would exceed this MUST instead be represented + * via `inputSchemaRef` / `openInputSchemaRef`. + * + * @category Canvas Limits + */ +export const CANVAS_SCHEMA_MAX_DEPTH = 4; + +/** + * Maximum declared actions per canvas — + * {@link CanvasReadyAvailabilityState.actions} and + * {@link CanvasTypeDeclaration.declaredActions}. Hosts MUST NOT declare more + * than this; a provider with a larger action surface MUST group or page + * actions out of band rather than exceed this bound. + * + * @category Canvas Limits + */ +export const CANVAS_MAX_DECLARED_ACTIONS = 64; + +/** + * Maximum UTF-16 code units of the JSON-serialized `input` for `openCanvas` + * or `invokeCanvasAction`. Hosts MUST reject a larger `input` with + * `InvalidParams` (`-32602`). + * + * @category Canvas Limits + */ +export const CANVAS_INPUT_MAX_LENGTH = 65536; + +/** + * Maximum UTF-16 code units of the JSON-serialized `result` returned by + * `invokeCanvasAction`. A provider reply that would exceed this MUST be + * represented out of band (e.g. a resource the client resolves separately) + * rather than returned inline — large results are bounded/lazy references, + * never persisted session-summary metadata. + * + * @category Canvas Limits + */ +export const CANVAS_RESULT_MAX_LENGTH = 65536; + +/** + * Returns whether an inline JSON Schema object satisfies + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} and {@link CANVAS_SCHEMA_MAX_DEPTH}. + * Hosts MUST reject (or represent via a `*Ref` field instead of inlining) + * any schema for which this returns `false`. + * + * Only walks `properties`-shaped nesting (recursing into any property value + * that itself looks like a nested object schema, i.e. carries its own + * `properties`). A schema that manages to exceed the property or depth bound + * through some other JSON Schema construct (e.g. `$ref`, `items`, + * `oneOf`/`anyOf`) is out of scope for this helper and MUST still be + * rejected by a conformant host. + * + * @category Canvas Limits + */ +export function isCanvasSchemaWithinLimits( + schema: { readonly properties?: Record }, + depth = 1, +): boolean { + const props = schema.properties; + if (!props) return true; + if (Object.keys(props).length > CANVAS_SCHEMA_MAX_PROPERTIES) return false; + for (const value of Object.values(props)) { + if (!isRecord(value)) continue; + const nestedProperties = value.properties; + if (!isRecord(nestedProperties)) continue; + if (depth >= CANVAS_SCHEMA_MAX_DEPTH) return false; + if (!isCanvasSchemaWithinLimits({ properties: nestedProperties }, depth + 1)) return false; + } + return true; +} + +/** Type predicate narrowing an arbitrary schema-property value to a plain object, so `.properties` can be read without a type assertion. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +// ─── Trust ─────────────────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasTrustState} — whether the host currently + * permits this canvas's declared actions to execute. + * + * Trust is independent of {@link CanvasAvailabilityStatus | availability}: + * a canvas may be perfectly capable of rendering while blocked from + * executing actions, and vice versa. Trust decisions are host/runtime + * authority, not something this protocol grants. + * + * @category Canvas Trust + * @nonexhaustive + */ +export const enum CanvasTrustStatus { + /** Declared actions may be invoked. */ + Trusted = 'trusted', + /** A trust decision has not yet been made (e.g. first use of a new/changed source). */ + Pending = 'pending', + /** The host has denied execution; declared actions MUST NOT be invoked. */ + Blocked = 'blocked', +} + +/** @category Canvas Trust */ +export interface CanvasTrustedState { + status: CanvasTrustStatus.Trusted; +} + +/** @category Canvas Trust */ +export interface CanvasPendingTrustState { + status: CanvasTrustStatus.Pending; +} + +/** @category Canvas Trust */ +export interface CanvasBlockedTrustState { + status: CanvasTrustStatus.Blocked; + /** Optional human-readable reason surfaced to the user. */ + reason?: string; +} + +/** + * Current trust decision governing whether a canvas's declared actions may + * execute. + * + * @category Canvas Trust + */ +export type CanvasTrustState = + | CanvasTrustedState + | CanvasPendingTrustState + | CanvasBlockedTrustState; + +// ─── Declared Actions ──────────────────────────────────────────────────────── + +/** + * One action a canvas declares it can perform, invoked via + * `invokeCanvasAction`. + * + * Declarations are carried only on the full {@link CanvasState}, loaded when + * a client subscribes — never duplicated into the lightweight + * {@link CanvasEntry} catalog entry, keeping session summaries small. + * + * @category Canvas Actions + */ +export interface CanvasActionDeclaration { + /** Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. */ + id: string; + /** Human-readable display name. */ + title?: string; + /** Description of what invoking the action does. */ + description?: string; + /** + * Inline JSON Schema for the expected `input`, when small enough to embed + * (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + * checked by {@link isCanvasSchemaWithinLimits}). Optional because some + * declared actions take no input. Mutually exclusive with + * `inputSchemaRef` — a declaration MUST supply at most one of the two. + */ + inputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** + * Bounded out-of-band reference to a larger JSON Schema, used instead of + * `inputSchema` when the schema would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. AHP does not mandate a specific resolution mechanism for this + * URI (e.g. a host MAY make it `resourceRead`-able). + */ + inputSchemaRef?: URI; +} + +/** + * A canvas type an installed extension or package currently makes available + * to open for a chat, as returned by `listCanvasTypes`. + * + * `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is + * unrelated to {@link CanvasEntry}, which represents durable membership of + * an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the + * catalogue (via `listCanvasTypes`) never opens, materializes, or restarts + * anything; only `openCanvas` does. + * + * @category Canvas State + */ +export interface CanvasTypeDeclaration { + /** The extension or package that declares this canvas type. */ + source: CanvasSource; + /** + * Provider-declared canvas type (host/provider-defined format), passed as + * {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + canvasType: string; + /** Human-readable display name for a canvas-type picker. */ + title: string; + /** Description of what this canvas type does. */ + description?: string; + /** Optional display icon. */ + icon?: Icon; + /** + * Inline JSON Schema describing the `openCanvas` `input` this type + * expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + * / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + * `openInputSchemaRef`. + */ + openInputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** + * Bounded out-of-band reference to a larger open-input JSON Schema, used + * instead of `openInputSchema` when it would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. + */ + openInputSchemaRef?: URI; + /** + * Advisory, statically-known preview of actions this canvas type + * typically declares once opened (bounded to + * {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + * the actual invocable actions for an opened instance are always + * {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + * depend on live provider configuration) and MUST be used instead of this + * preview once the canvas is open. + */ + declaredActions?: CanvasActionDeclaration[]; +} + +/** + * Transient, renderer-neutral presentation of a canvas's current live + * endpoint, returned by `resolveCanvasSource`. + * + * This is a plain URL, not any renderer- or process-model-specific handle + * (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview + * panel reference) — how a client actually presents it (a VS Code Webview, + * the Integrated Browser, or otherwise) is entirely a client/host + * implementation detail outside this protocol. + * + * @category Canvas State + */ +export interface CanvasSourcePresentation { + /** + * Ephemeral URL to the canvas's current live endpoint. Transient — MUST + * NOT be persisted, cached beyond the current read, or treated as a + * stable/durable identity. A host MAY embed short-lived, single-use + * credentials in it; such credentials are never durable authority. + */ + url: string; + /** Advisory expiry hint for `url` (and any embedded credential), if the host bounds their validity. */ + expiresAt?: string; +} + +// ─── Availability ──────────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasAvailabilityState} — the canvas's current + * live resolution state, independent of its durable + * {@link CanvasEntry | membership} in a session's catalog. + * + * An empty catalog membership list is not itself a close, and a canvas may + * remain a recorded member while its live availability cycles through these + * states any number of times (e.g. across provider restarts). + * + * @category Canvas Availability + * @nonexhaustive + */ +export const enum CanvasAvailabilityStatus { + /** + * The connected client or host does not support this canvas type (e.g. + * the client omitted the `canvases` capability, or no local runtime can + * render this `canvasType`). Distinct from `blocked` trust, which is a + * policy decision rather than a capability gap. + */ + Unsupported = 'unsupported', + /** Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. */ + NotLoaded = 'notLoaded', + /** Currently resolving or (re)connecting to a live endpoint. */ + Loading = 'loading', + /** Live and reachable, but the provider has not yet produced content to render. */ + Empty = 'empty', + /** Live, reachable, and has declared its current actions. */ + Ready = 'ready', + /** The live endpoint failed to resolve, or resolution otherwise failed. */ + Failed = 'failed', +} + +/** @category Canvas Availability */ +export interface CanvasUnsupportedAvailabilityState { + status: CanvasAvailabilityStatus.Unsupported; +} + +/** @category Canvas Availability */ +export interface CanvasNotLoadedAvailabilityState { + status: CanvasAvailabilityStatus.NotLoaded; +} + +/** @category Canvas Availability */ +export interface CanvasLoadingAvailabilityState { + status: CanvasAvailabilityStatus.Loading; +} + +/** @category Canvas Availability */ +export interface CanvasEmptyAvailabilityState { + status: CanvasAvailabilityStatus.Empty; +} + +/** + * @category Canvas Availability + */ +export interface CanvasReadyAvailabilityState { + status: CanvasAvailabilityStatus.Ready; + /** Actions currently declared by the live provider (full replacement each time this state is produced). */ + actions: CanvasActionDeclaration[]; +} + +/** @category Canvas Availability */ +export interface CanvasFailedAvailabilityState { + status: CanvasAvailabilityStatus.Failed; + /** Stable machine-readable and human-readable failure information. */ + error: ErrorInfo; +} + +/** + * Current live resolution state of a canvas. + * + * @category Canvas Availability + */ +export type CanvasAvailabilityState = + | CanvasUnsupportedAvailabilityState + | CanvasNotLoadedAvailabilityState + | CanvasLoadingAvailabilityState + | CanvasEmptyAvailabilityState + | CanvasReadyAvailabilityState + | CanvasFailedAvailabilityState; + +// ─── Catalog Entry ─────────────────────────────────────────────────────────── + +/** + * Lightweight catalog entry for a canvas, carried in + * {@link SessionState.canvases | `SessionState.canvases`}. Presence + * represents durable **logical membership** — it is unaffected by the live + * {@link CanvasEntry.availability | `availability`} cycling through + * `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. + * + * The full state, including declared actions, lives in {@link CanvasState}, + * loaded when a client subscribes to {@link CanvasEntry.resource}. + * + * @category Canvas State + */ +export interface CanvasEntry { + /** Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. */ + resource: URI; + /** Full identity, including current incarnation. */ + identity: CanvasIdentity; + /** Human-readable display title. */ + title: string; + /** Optional display icon. */ + icon?: Icon; + /** Current trust decision matching {@link CanvasState.trust}. */ + trust: CanvasTrustState; + /** Current availability status matching {@link CanvasState.availability}'s discriminant. */ + availability: CanvasAvailabilityStatus; + /** + * Monotonically increasing counter bumped on every change to this + * canvas's state (trust, availability, or incarnation). Clients MAY use it + * to detect and reject stale reads without a full deep comparison. + */ + revision: number; + /** Opaque host-defined summary metadata. */ + _meta?: Record; +} + +/** + * Full state for a single canvas, loaded when a client subscribes to the + * canvas's URI. + * + * `CanvasState` **denormalizes** every {@link CanvasEntry} field directly + * onto itself, replacing `availability`'s lightweight status with the full + * {@link CanvasAvailabilityState} (including declared actions or failure + * detail). Producers MUST keep the two representations consistent: any + * change to the inlined fields SHOULD also be announced on the owning + * session via {@link SessionCanvasSetAction | `session/canvasSet`}. + * + * @category Canvas State + */ +export interface CanvasState { + /** URI of this canvas channel. */ + resource: URI; + /** Full identity, including current incarnation. */ + identity: CanvasIdentity; + /** Human-readable display title. */ + title: string; + /** Optional display icon. */ + icon?: Icon; + /** Current trust decision. */ + trust: CanvasTrustState; + /** Current live resolution state. */ + availability: CanvasAvailabilityState; + /** Matches {@link CanvasEntry.revision}. */ + revision: number; + /** Opaque host-defined metadata. */ + _meta?: Record; +} diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 4edf17b3..f8329509 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -17,6 +17,7 @@ import type { import type { URI } from '../common/state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { ChatSummary } from '../channels-chat/state.js'; +import type { CanvasEntry } from '../channels-canvas/state.js'; // ─── Session Actions ───────────────────────────────────────────────────────── @@ -192,6 +193,44 @@ export interface SessionChangesetsChangedAction { changesets: Changeset[] | undefined; } +/** + * A canvas was admitted (opened) or its catalog entry changed. + * + * Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the + * server dispatches this with the full entry to record a newly opened + * canvas, or to republish it after a trust/availability/incarnation change + * so subscribers following only the session channel stay in sync with + * {@link CanvasState}. Never client-dispatchable — canvases are admitted + * only through the `openCanvas` command. A stale/out-of-order delivery + * (`canvas.revision` not strictly greater than the currently-recorded + * entry's revision) MUST be rejected (no-op) rather than overwrite a newer + * entry with older data. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCanvasSetAction { + type: ActionType.SessionCanvasSet; + /** The canvas entry to add or update, matched by `resource`. */ + canvas: CanvasEntry; +} + +/** + * A canvas was logically closed. + * + * Remove semantics keyed by `resource`: an unknown URI is a no-op. This + * represents durable membership removal, not a client hiding a local + * tab/view — see `closeCanvas`. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCanvasRemovedAction { + type: ActionType.SessionCanvasRemoved; + /** Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. */ + resource: URI; +} + /** * Server tools for this session have changed. * diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index b497db3e..dabdb228 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -223,6 +223,34 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: : stateWithoutChangesets; } + case ActionType.SessionCanvasSet: { + const list = state.canvases ?? []; + const idx = list.findIndex(c => c.resource === action.canvas.resource); + if (idx < 0) { + return { ...state, canvases: [...list, action.canvas] }; + } + // Reject a stale/out-of-order membership update rather than let it + // overwrite a newer catalog entry with older data. + if (action.canvas.revision <= list[idx].revision) return state; + const updated = list.slice(); + updated[idx] = action.canvas; + return { ...state, canvases: updated }; + } + + case ActionType.SessionCanvasRemoved: { + const list = state.canvases; + if (!list) { + return state; + } + const idx = list.findIndex(c => c.resource === action.resource); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, canvases: updated }; + } + case ActionType.SessionConfigChanged: if (!state.config) { return state; diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index 78db1000..59553cfe 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -7,6 +7,7 @@ */ import type { Changeset } from '../channels-changeset/state.js'; +import type { CanvasEntry } from '../channels-canvas/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; import type { ChatSummary, @@ -219,6 +220,15 @@ export interface SessionState extends SessionMetadata { * {@link /guide/changesets | Changesets} for an overview of the model. */ changesets?: Changeset[]; + /** + * Catalog of canvases opened for chats in this session. Presence is + * durable logical membership, admitted only via `openCanvas` — never + * implied by a chat's existence or a client's earlier focus. Each entry's + * {@link CanvasIdentity.chat | `identity.chat`} identifies the exact + * backing chat; a canvas never migrates to a different chat. See + * {@link CanvasEntry} for the full membership/availability/trust model. + */ + canvases?: CanvasEntry[]; /** * Outstanding input the session is blocked on, aggregated across every chat * so a client can discover and answer it from the session channel alone, diff --git a/types/commands.ts b/types/commands.ts index 700bbfa0..4470f3cf 100644 --- a/types/commands.ts +++ b/types/commands.ts @@ -15,3 +15,4 @@ export * from './channels-terminal/commands.js'; export * from './channels-changeset/commands.js'; export * from './channels-resource-watch/commands.js'; export * from './channels-automation/commands.js'; +export * from './channels-canvas/commands.js'; diff --git a/types/common/actions.ts b/types/common/actions.ts index 99cc4276..4e84a95b 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -44,6 +44,8 @@ import type { SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction, + SessionCanvasSetAction, + SessionCanvasRemovedAction, } from '../channels-session/actions.js'; import type { @@ -128,6 +130,12 @@ import type { AutomationRunPrimarySessionChangedAction, AutomationRunCancelRequestedAction, } from '../channels-automation-run/actions.js'; +import type { + CanvasAvailabilityChangedAction, + CanvasTrustChangedAction, + CanvasIncarnationChangedAction, + CanvasTitleChangedAction, +} from '../channels-canvas/actions.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -234,6 +242,12 @@ export const enum ActionType { AutomationRunSessionRemoved = 'automationRun/sessionRemoved', AutomationRunPrimarySessionChanged = 'automationRun/primarySessionChanged', AutomationRunCancelRequested = 'automationRun/cancelRequested', + SessionCanvasSet = 'session/canvasSet', + SessionCanvasRemoved = 'session/canvasRemoved', + CanvasAvailabilityChanged = 'canvas/availabilityChanged', + CanvasTrustChanged = 'canvas/trustChanged', + CanvasIncarnationChanged = 'canvas/incarnationChanged', + CanvasTitleChanged = 'canvas/titleChanged', } // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -302,6 +316,8 @@ export type StateAction = | SessionChangesetsChangedAction | SessionConfigChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction | ChatTurnStartedAction | ChatDeltaAction | ChatResponsePartAction @@ -365,4 +381,8 @@ export type StateAction = | AutomationRunSessionSetAction | AutomationRunSessionRemovedAction | AutomationRunPrimarySessionChangedAction - | AutomationRunCancelRequestedAction; + | AutomationRunCancelRequestedAction + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction; diff --git a/types/common/commands.ts b/types/common/commands.ts index d2bff1e8..f93bbdd4 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -219,6 +219,25 @@ export interface ClientCapabilities { * App-bearing tool calls as ordinary MCP tool calls. */ mcpApps?: Record; + /** + * Client can render local canvases: `listCanvasTypes`, `openCanvas`, + * subscribe to the resulting `ahp-canvas:` channel, and drive + * `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + * `closeCanvas`. + * + * Hosts SHOULD NOT offer canvas admission to a client that omits this + * capability; such a client MUST be treated as if every canvas were + * {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + * anything about server/runtime execution trust — see + * {@link CanvasTrustStatus}, which is a separate, host-owned decision. + * + * This declares only the CLIENT's rendering capability. Protocol version + * support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + * actually has a working canvas runtime — see + * {@link InitializeResult.canvases}, the server-side counterpart, which a + * client MUST also check before treating canvases as usable. + */ + canvases?: Record; } /** @@ -293,8 +312,35 @@ export interface InitializeResult { * @see {@link /guide/automations | Automations Guide} */ automations?: AutomationCapabilities; + /** + * Host/runtime-owned local-canvas support. Presence means the SERVER + * currently has a working runtime able to serve `openCanvas` / + * `invokeCanvasAction` for at least one qualifying (explicitly installed + * and trust-eligible) extension/package source; absence means the host + * has no available canvas runtime, and clients MUST treat every canvas as + * {@link CanvasAvailabilityStatus.Unsupported} regardless of what + * {@link ClientCapabilities.canvases} declared. + * + * **Protocol version support alone is not a runtime capability**: a host + * speaking protocol `>= 0.10.0` without this field present MUST NOT be + * assumed to have a usable canvas runtime. This field — not the + * negotiated `protocolVersion` — is the authoritative signal, and is + * independent of any individual canvas's live availability + * ({@link CanvasAvailabilityState}) or trust decision + * ({@link CanvasTrustState}). + */ + canvases?: CanvasCapabilities; } +/** + * Local-canvas runtime features supported by this host authority. The empty + * object means "supported" — see {@link InitializeResult.canvases} for what + * presence/absence of this field itself means. + * + * @category Commands + */ +export interface CanvasCapabilities {} + /** * Automation features supported by this host authority. * diff --git a/types/common/messages.ts b/types/common/messages.ts index 4ffe0987..2d0bb1f9 100644 --- a/types/common/messages.ts +++ b/types/common/messages.ts @@ -78,6 +78,18 @@ import type { FetchAutomationRunsParams, FetchAutomationRunsResult, } from '../channels-automation/commands.js'; +import type { + ListCanvasTypesParams, + ListCanvasTypesResult, + OpenCanvasParams, + OpenCanvasResult, + ResolveCanvasSourceParams, + ResolveCanvasSourceResult, + InvokeCanvasActionParams, + InvokeCanvasActionResult, + RestartCanvasProviderParams, + CloseCanvasParams, +} from '../channels-canvas/commands.js'; import type { ActionEnvelope } from './actions.js'; import type { @@ -185,6 +197,12 @@ export interface CommandMap { 'listAutomationTriggerDefinitions': { params: ListAutomationTriggerDefinitionsParams; result: ListAutomationTriggerDefinitionsResult }; 'runAutomation': { params: RunAutomationParams; result: RunAutomationResult }; 'fetchAutomationRuns': { params: FetchAutomationRunsParams; result: FetchAutomationRunsResult }; + 'listCanvasTypes': { params: ListCanvasTypesParams; result: ListCanvasTypesResult }; + 'openCanvas': { params: OpenCanvasParams; result: OpenCanvasResult }; + 'resolveCanvasSource': { params: ResolveCanvasSourceParams; result: ResolveCanvasSourceResult }; + 'invokeCanvasAction': { params: InvokeCanvasActionParams; result: InvokeCanvasActionResult }; + 'restartCanvasProvider': { params: RestartCanvasProviderParams; result: null }; + 'closeCanvas': { params: CloseCanvasParams; result: null }; } /** diff --git a/types/common/reducer-helpers.ts b/types/common/reducer-helpers.ts index 02a9d46e..61c73ba6 100644 --- a/types/common/reducer-helpers.ts +++ b/types/common/reducer-helpers.ts @@ -20,6 +20,8 @@ import type { ClientAutomationAction, AutomationRunAction, ClientAutomationRunAction, + CanvasAction, + ClientCanvasAction, } from '../action-origin.generated.js'; import { IS_CLIENT_DISPATCHABLE } from '../action-origin.generated.js'; @@ -44,6 +46,6 @@ export function softAssertNever(value: never, log?: (msg: string) => void): void * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction { +export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction | CanvasAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | ClientCanvasAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/types/common/state.ts b/types/common/state.ts index b56048c6..17dae020 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -16,6 +16,7 @@ import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; import type { AutomationState } from '../channels-automation/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; +import type { CanvasState } from '../channels-canvas/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -335,7 +336,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/types/index.ts b/types/index.ts index 048e196f..ca0a4c6c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -31,5 +31,6 @@ export { resourceWatchReducer, automationReducer, automationRunReducer, + canvasReducer, isClientDispatchable, } from './reducers.js'; diff --git a/types/messages.test.ts b/types/messages.test.ts index 7abe8b02..3e5d9d1c 100644 --- a/types/messages.test.ts +++ b/types/messages.test.ts @@ -35,6 +35,7 @@ function readChannelSources(baseName: string): string { 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-canvas', ]; return dirs .map(dir => { diff --git a/types/reducers.test.ts b/types/reducers.test.ts index cc90200a..68a24ead 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -27,11 +27,12 @@ import { resourceWatchReducer, automationReducer, automationRunReducer, + canvasReducer, isClientDispatchable, } from './reducers.js'; import { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; import { ActionType } from './actions.js'; -import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState } from './state.js'; +import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState, CanvasState } from './state.js'; import { SessionStatus, TurnState, @@ -58,6 +59,7 @@ function readChannelSources(baseName: string): string { 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-canvas', ]; return dirs .map(dir => { @@ -73,11 +75,11 @@ function readChannelSources(baseName: string): string { // ─── Fixture Loading ───────────────────────────────────────────────────────── -type FixtureState = RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState; +type FixtureState = RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState | CanvasState; interface Fixture { description: string; - reducer: 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; + reducer: 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun' | 'canvas'; initial: FixtureState; actions: unknown[]; expected: FixtureState; @@ -131,6 +133,8 @@ describe('reducer fixtures', () => { state = automationReducer(state as AutomationState, action as any); } else if (fixture.reducer === 'automationRun') { state = automationRunReducer(state as AutomationRunState, action as any); + } else if (fixture.reducer === 'canvas') { + state = canvasReducer(state as CanvasState, action as any); } else { state = sessionReducer(state as SessionState, action as any); } @@ -210,6 +214,22 @@ describe('isClientDispatchable', () => { const action = { type: ActionType.SessionReady, session: 'x' } as const; assert.equal(isClientDispatchable(action), false); }); + + // Regression: canvas live-state and session-canvas-membership actions + // reflect authoritative host resolution outcomes (identity, trust, + // availability, membership), not client-optimistic writes — they MUST + // remain server-only. A client attempting to dispatch one of these + // directly would be forging state the host alone is authoritative for. + it('returns false for every canvas-scoped action (server-only)', () => { + assert.equal(isClientDispatchable({ type: ActionType.CanvasAvailabilityChanged, availability: { status: 'notLoaded' }, revision: 1 } as const), false); + assert.equal(isClientDispatchable({ type: ActionType.CanvasTrustChanged, trust: { status: 'pending' }, revision: 1 } as const), false); + assert.equal(isClientDispatchable({ type: ActionType.CanvasIncarnationChanged, incarnation: 'gen-1', revision: 1 } as const), false); + assert.equal(isClientDispatchable({ type: ActionType.CanvasTitleChanged, title: 'x', revision: 1 } as const), false); + }); + + it('returns false for session canvas-membership actions (server-only)', () => { + assert.equal(isClientDispatchable({ type: ActionType.SessionCanvasRemoved, resource: 'ahp-canvas:/c1' } as const), false); + }); }); // ─── Immutability Checks ───────────────────────────────────────────────────── diff --git a/types/reducers.ts b/types/reducers.ts index f6cbdb31..b4b711e2 100644 --- a/types/reducers.ts +++ b/types/reducers.ts @@ -14,4 +14,5 @@ export { annotationsReducer } from './channels-annotations/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/reducer.js'; export { automationReducer } from './channels-automation/reducer.js'; export { automationRunReducer } from './channels-automation-run/reducer.js'; +export { canvasReducer } from './channels-canvas/reducer.js'; export { softAssertNever, isClientDispatchable } from './common/reducer-helpers.js'; diff --git a/types/state.ts b/types/state.ts index 28b13bfd..8346ffe7 100644 --- a/types/state.ts +++ b/types/state.ts @@ -18,3 +18,4 @@ export * from './channels-otlp/state.js'; export * from './channels-resource-watch/state.js'; export * from './channels-automation/state.js'; export * from './channels-automation-run/state.js'; +export * from './channels-canvas/state.js'; diff --git a/types/test-cases/reducers/280-canvas-availabilitychanged-updates-state-and-bumps-revision.json b/types/test-cases/reducers/280-canvas-availabilitychanged-updates-state-and-bumps-revision.json new file mode 100644 index 00000000..db0a083e --- /dev/null +++ b/types/test-cases/reducers/280-canvas-availabilitychanged-updates-state-and-bumps-revision.json @@ -0,0 +1,40 @@ +{ + "description": "canvas/availabilityChanged replaces availability and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "notLoaded" }, + "revision": 1 + }, + "actions": [ + { + "type": "canvas/availabilityChanged", + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } +} + diff --git a/types/test-cases/reducers/281-canvas-trustchanged-blocks-and-bumps-revision.json b/types/test-cases/reducers/281-canvas-trustchanged-blocks-and-bumps-revision.json new file mode 100644 index 00000000..ede25c79 --- /dev/null +++ b/types/test-cases/reducers/281-canvas-trustchanged-blocks-and-bumps-revision.json @@ -0,0 +1,36 @@ +{ + "description": "canvas/trustChanged replaces trust and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "package", "sourceId": "pkg-contoso-widgets-workspace", "packageName": "contoso-widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": { "status": "notLoaded" }, + "revision": 1 + }, + "actions": [ + { "type": "canvas/trustChanged", "trust": { "status": "blocked", "reason": "unreviewed package" }, "revision": 2 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "package", "sourceId": "pkg-contoso-widgets-workspace", "packageName": "contoso-widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "blocked", "reason": "unreviewed package" }, + "availability": { "status": "notLoaded" }, + "revision": 2 + } +} + diff --git a/types/test-cases/reducers/282-canvas-incarnationchanged-bumps-incarnation-and-revision.json b/types/test-cases/reducers/282-canvas-incarnationchanged-bumps-incarnation-and-revision.json new file mode 100644 index 00000000..23723b1c --- /dev/null +++ b/types/test-cases/reducers/282-canvas-incarnationchanged-bumps-incarnation-and-revision.json @@ -0,0 +1,36 @@ +{ + "description": "canvas/incarnationChanged replaces the opaque incarnation token and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 4 + }, + "actions": [ + { "type": "canvas/incarnationChanged", "incarnation": "gen-2", "revision": 5 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 5 + } +} + diff --git a/types/test-cases/reducers/283-canvas-titlechanged-updates-title-and-bumps-revision.json b/types/test-cases/reducers/283-canvas-titlechanged-updates-title-and-bumps-revision.json new file mode 100644 index 00000000..3ef29a1d --- /dev/null +++ b/types/test-cases/reducers/283-canvas-titlechanged-updates-title-and-bumps-revision.json @@ -0,0 +1,36 @@ +{ + "description": "canvas/titleChanged replaces title and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 1 + }, + "actions": [ + { "type": "canvas/titleChanged", "title": "Renamed Widget", "revision": 2 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Renamed Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 2 + } +} + diff --git a/types/test-cases/reducers/284-canvas-unknown-action-type-is-no-op.json b/types/test-cases/reducers/284-canvas-unknown-action-type-is-no-op.json new file mode 100644 index 00000000..d464b3bb --- /dev/null +++ b/types/test-cases/reducers/284-canvas-unknown-action-type-is-no-op.json @@ -0,0 +1,36 @@ +{ + "description": "canvas unknown action type is a no-op", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 1 + }, + "actions": [ + { "type": "canvas/unknownAction" } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 1 + } +} + diff --git a/types/test-cases/reducers/285-session-canvasset-appends-new-canvas.json b/types/test-cases/reducers/285-session-canvasset-appends-new-canvas.json new file mode 100644 index 00000000..aa5d4df4 --- /dev/null +++ b/types/test-cases/reducers/285-session-canvasset-appends-new-canvas.json @@ -0,0 +1,55 @@ +{ + "description": "session/canvasSet appends a new canvas entry when its resource is unknown", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": "notLoaded", + "revision": 1 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": "notLoaded", + "revision": 1 + } + ] + } +} diff --git a/types/test-cases/reducers/286-session-canvasset-replaces-existing-canvas.json b/types/test-cases/reducers/286-session-canvasset-replaces-existing-canvas.json new file mode 100644 index 00000000..f157c600 --- /dev/null +++ b/types/test-cases/reducers/286-session-canvasset-replaces-existing-canvas.json @@ -0,0 +1,71 @@ +{ + "description": "session/canvasSet replaces an existing canvas entry matched by resource", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": "notLoaded", + "revision": 1 + } + ] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + ] + } +} diff --git a/types/test-cases/reducers/287-session-canvasremoved-removes-matching-and-noop-unknown.json b/types/test-cases/reducers/287-session-canvasremoved-removes-matching-and-noop-unknown.json new file mode 100644 index 00000000..556f1518 --- /dev/null +++ b/types/test-cases/reducers/287-session-canvasremoved-removes-matching-and-noop-unknown.json @@ -0,0 +1,41 @@ +{ + "description": "session/canvasRemoved removes the matching canvas entry and is a no-op for an unknown resource", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + ] + }, + "actions": [ + { "type": "session/canvasRemoved", "resource": "ahp-canvas:/unknown" }, + { "type": "session/canvasRemoved", "resource": "ahp-canvas:/c1" } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [] + } +} diff --git a/types/test-cases/reducers/288-session-canvasremoved-noop-without-canvases-list.json b/types/test-cases/reducers/288-session-canvasremoved-noop-without-canvases-list.json new file mode 100644 index 00000000..5b227c5d --- /dev/null +++ b/types/test-cases/reducers/288-session-canvasremoved-noop-without-canvases-list.json @@ -0,0 +1,23 @@ +{ + "description": "session/canvasRemoved is a no-op when the session has no canvases catalogue at all", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { "type": "session/canvasRemoved", "resource": "ahp-canvas:/c1" } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + } +} diff --git a/types/test-cases/reducers/289-canvas-rejects-stale-revision-including-a-would-be-incarnation-regression.json b/types/test-cases/reducers/289-canvas-rejects-stale-revision-including-a-would-be-incarnation-regression.json new file mode 100644 index 00000000..4fb019ae --- /dev/null +++ b/types/test-cases/reducers/289-canvas-rejects-stale-revision-including-a-would-be-incarnation-regression.json @@ -0,0 +1,36 @@ +{ + "description": "canvas reducer rejects (no-ops) an action whose asserted revision is not strictly greater than the current revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 5 + }, + "actions": [ + { "type": "canvas/titleChanged", "title": "Stale duplicate title", "revision": 5 }, + { "type": "canvas/incarnationChanged", "incarnation": "gen-1", "revision": 3 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 5 + } +} diff --git a/types/test-cases/reducers/290-session-canvasset-rejects-stale-revision.json b/types/test-cases/reducers/290-session-canvasset-rejects-stale-revision.json new file mode 100644 index 00000000..6c9111f4 --- /dev/null +++ b/types/test-cases/reducers/290-session-canvasset-rejects-stale-revision.json @@ -0,0 +1,71 @@ +{ + "description": "session/canvasSet rejects (no-ops) a stale/duplicate catalog update whose canvas.revision is not strictly greater than the currently-recorded entry's revision", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 5 + } + ] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Stale Widget Title", + "trust": { "status": "trusted" }, + "availability": "notLoaded", + "revision": 3 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 5 + } + ] + } +} diff --git a/types/version/message-checks.ts b/types/version/message-checks.ts index 3d6cc2c3..e94bd4a8 100644 --- a/types/version/message-checks.ts +++ b/types/version/message-checks.ts @@ -79,7 +79,13 @@ type _ExpectedCommands = | 'invokeChangesetOperation' | 'listAutomationTriggerDefinitions' | 'runAutomation' - | 'fetchAutomationRuns'; + | 'fetchAutomationRuns' + | 'listCanvasTypes' + | 'openCanvas' + | 'resolveCanvasSource' + | 'invokeCanvasAction' + | 'restartCanvasProvider' + | 'closeCanvas'; /** All methods annotated `@messageType Notification` (client → server). */ type _ExpectedClientNotifications = diff --git a/types/version/registry.ts b/types/version/registry.ts index ddddc86e..b25ccf28 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.9.0'; +export const PROTOCOL_VERSION = '0.10.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,6 +34,7 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -176,6 +177,12 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.AutomationRunSessionRemoved]: '0.8.0', [ActionType.AutomationRunPrimarySessionChanged]: '0.8.0', [ActionType.AutomationRunCancelRequested]: '0.8.0', + [ActionType.SessionCanvasSet]: '0.10.0', + [ActionType.SessionCanvasRemoved]: '0.10.0', + [ActionType.CanvasAvailabilityChanged]: '0.10.0', + [ActionType.CanvasTrustChanged]: '0.10.0', + [ActionType.CanvasIncarnationChanged]: '0.10.0', + [ActionType.CanvasTitleChanged]: '0.10.0', }; /** From f122130ddb3d5968e2ebfc5c8d6603b014e459ba Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sat, 12 Sep 2026 00:07:06 +0200 Subject: [PATCH 2/3] canvas: chore: align protocol guards with consumer lint Add braces to the existing canvas reducer, membership and schema-limit conditions without changing their behavior or protocol shape. Fix the canonical sources so downstream generated copies remain reproducible. Generation, types, lint and the Node coverage suite pass. The existing release-time changelog heading gate remains unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a069b1a2-65a9-4427-b3fe-6546a3bffc9e --- types/channels-canvas/reducer.ts | 17 ++++++++++++----- types/channels-canvas/state.ts | 24 ++++++++++++++++++------ types/channels-session/reducer.ts | 4 +++- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/types/channels-canvas/reducer.ts b/types/channels-canvas/reducer.ts index 7ca95527..c9371273 100644 --- a/types/channels-canvas/reducer.ts +++ b/types/channels-canvas/reducer.ts @@ -24,15 +24,21 @@ import { softAssertNever } from '../common/reducer-helpers.js'; export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (msg: string) => void): CanvasState { switch (action.type) { case ActionType.CanvasAvailabilityChanged: - if (action.revision <= state.revision) return state; + if (action.revision <= state.revision) { + return state; + } return { ...state, availability: action.availability, revision: action.revision }; case ActionType.CanvasTrustChanged: - if (action.revision <= state.revision) return state; + if (action.revision <= state.revision) { + return state; + } return { ...state, trust: action.trust, revision: action.revision }; case ActionType.CanvasIncarnationChanged: - if (action.revision <= state.revision) return state; + if (action.revision <= state.revision) { + return state; + } return { ...state, identity: { ...state.identity, incarnation: action.incarnation }, @@ -40,7 +46,9 @@ export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (m }; case ActionType.CanvasTitleChanged: - if (action.revision <= state.revision) return state; + if (action.revision <= state.revision) { + return state; + } return { ...state, title: action.title, revision: action.revision }; default: @@ -48,4 +56,3 @@ export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (m return state; } } - diff --git a/types/channels-canvas/state.ts b/types/channels-canvas/state.ts index a2a59bfd..abbfc7d2 100644 --- a/types/channels-canvas/state.ts +++ b/types/channels-canvas/state.ts @@ -255,14 +255,26 @@ export function isCanvasSchemaWithinLimits( depth = 1, ): boolean { const props = schema.properties; - if (!props) return true; - if (Object.keys(props).length > CANVAS_SCHEMA_MAX_PROPERTIES) return false; + if (!props) { + return true; + } + if (Object.keys(props).length > CANVAS_SCHEMA_MAX_PROPERTIES) { + return false; + } for (const value of Object.values(props)) { - if (!isRecord(value)) continue; + if (!isRecord(value)) { + continue; + } const nestedProperties = value.properties; - if (!isRecord(nestedProperties)) continue; - if (depth >= CANVAS_SCHEMA_MAX_DEPTH) return false; - if (!isCanvasSchemaWithinLimits({ properties: nestedProperties }, depth + 1)) return false; + if (!isRecord(nestedProperties)) { + continue; + } + if (depth >= CANVAS_SCHEMA_MAX_DEPTH) { + return false; + } + if (!isCanvasSchemaWithinLimits({ properties: nestedProperties }, depth + 1)) { + return false; + } } return true; } diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index dabdb228..096c5baa 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -231,7 +231,9 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: } // Reject a stale/out-of-order membership update rather than let it // overwrite a newer catalog entry with older data. - if (action.canvas.revision <= list[idx].revision) return state; + if (action.canvas.revision <= list[idx].revision) { + return state; + } const updated = list.slice(); updated[idx] = action.canvas; return { ...state, canvases: updated }; From 46a475240e0300a6fb4fe8b1d919a3f9ba6966b3 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sun, 13 Sep 2026 17:07:56 +0200 Subject: [PATCH 3/3] canvas: fix: complete native client protocol parity Implement revision-checked reducers and lossless canvas snapshots across native clients. Preserve host isolation, empty catalogues and opaque incarnations, with shared conformance and native mirror regressions. Keep the preview capability-gated on the current protocol version rather than reserving an unauthorized release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a069b1a2-65a9-4427-b3fe-6546a3bffc9e --- clients/dotnet/release-metadata.json | 1 - .../Generated/Commands.generated.cs | 4 +- .../Generated/State.generated.cs | 28 +++++- .../Generated/Version.generated.cs | 3 +- .../dotnet/src/AgentHostProtocol/Reducers.cs | 71 +++++++++++++++ .../FixtureDrivenReducerTests.cs | 3 + clients/go/ahp/reducers.go | 53 ++++++++++++ clients/go/ahp/reducers_fixture_test.go | 2 + clients/go/ahptypes/commands.generated.go | 4 +- clients/go/ahptypes/state.generated.go | 22 +++++ clients/go/ahptypes/version.generated.go | 3 +- clients/go/release-metadata.json | 1 - clients/kotlin/release-metadata.json | 1 - .../microsoft/agenthostprotocol/Reducers.kt | 50 +++++++++++ .../generated/Commands.generated.kt | 4 +- .../generated/State.generated.kt | 4 + .../generated/Version.generated.kt | 3 +- .../FixtureDrivenReducerTest.kt | 9 ++ clients/rust/crates/ahp-types/src/actions.rs | 7 +- clients/rust/crates/ahp-types/src/commands.rs | 12 +-- clients/rust/crates/ahp-types/src/state.rs | 1 + clients/rust/crates/ahp-types/src/version.rs | 7 +- .../crates/ahp/src/multi_host_state_mirror.rs | 23 ++++- clients/rust/crates/ahp/src/reducers.rs | 86 +++++++++++++++++-- .../ahp/tests/multi_host_state_mirror.rs | 52 ++++++++++- clients/rust/release-metadata.json | 1 - .../Generated/Commands.generated.swift | 4 +- .../Generated/State.generated.swift | 14 +-- .../Generated/Version.generated.swift | 3 +- .../AgentHostProtocol/NativeReducer.swift | 12 +++ .../Sources/AgentHostProtocol/Reducers.swift | 44 ++++++++++ .../AHPStateMirror.swift | 7 ++ .../MultiHostStateMirror.swift | 9 ++ .../AHPStateMirrorTests.swift | 30 +++++++ .../MultiHostStateMirrorTests.swift | 22 +++++ .../FixtureDrivenReducerTests.swift | 4 + clients/swift/release-metadata.json | 1 - clients/typescript/release-metadata.json | 1 - .../20260913-canvas-client-parity.json | 5 ++ schema/commands.schema.json | 4 +- schema/errors.schema.json | 4 +- scripts/generate-csharp.ts | 34 +++++--- scripts/generate-go.ts | 24 ++++++ scripts/generate-kotlin.ts | 4 + scripts/generate-rust.ts | 5 +- scripts/generate-swift.ts | 16 ++-- types/common/commands.ts | 4 +- .../round-trips/050-canvas-snapshot.json | 42 +++++++++ types/version/registry.test.ts | 12 +++ types/version/registry.ts | 15 ++-- 50 files changed, 687 insertions(+), 88 deletions(-) create mode 100644 docs/.changes/20260913-canvas-client-parity.json create mode 100644 types/test-cases/round-trips/050-canvas-snapshot.json diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json index c745db9d..128ac239 100644 --- a/clients/dotnet/release-metadata.json +++ b/clients/dotnet/release-metadata.json @@ -2,7 +2,6 @@ "client": "dotnet", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 009523d0..12d99f36 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -228,7 +228,7 @@ public sealed record InitializeResult /// {@link ClientCapabilities.canvases} declared. /// /// **Protocol version support alone is not a runtime capability**: a host - /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// speaking a supported protocol version without this field present MUST NOT be /// assumed to have a usable canvas runtime. This field — not the /// negotiated `protocolVersion` — is the authoritative signal, and is /// independent of any individual canvas's live availability @@ -300,7 +300,7 @@ public sealed record ClientCapabilities /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. /// /// This declares only the CLIENT's rendering capability. Protocol version - /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// support alone is not evidence that the SERVER /// actually has a working canvas runtime — see /// {@link InitializeResult.canvases}, the server-side counterpart, which a /// client MUST also check before treating canvases as usable. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index c75a9813..5ae4a43d 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -5541,6 +5541,8 @@ public sealed class AutomationRunState /// values that differ only in `version` are the same source). public sealed record CanvasExtensionSource { + public CanvasSourceKind Kind { get; init; } + /// Stable extension identifier (host-defined format, e.g. `publisher.name`). /// MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. public required string ExtensionId { get; init; } @@ -5561,6 +5563,8 @@ public sealed record CanvasExtensionSource /// only and MUST NOT be treated as identity-bearing. public sealed record CanvasPackageSource { + public CanvasSourceKind Kind { get; init; } + /// Stable, host- or package-manager-assigned unique identifier for this /// specific installed package instance/scope (opaque format). This is the /// identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed @@ -5652,14 +5656,18 @@ public sealed record CanvasIdentity public sealed record CanvasTrustedState { + public CanvasTrustStatus Status { get; init; } } public sealed record CanvasPendingTrustState { + public CanvasTrustStatus Status { get; init; } } public sealed record CanvasBlockedTrustState { + public CanvasTrustStatus Status { get; init; } + /// Optional human-readable reason surfaced to the user. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Reason { get; init; } @@ -5703,28 +5711,36 @@ public sealed record CanvasActionDeclaration public sealed record CanvasUnsupportedAvailabilityState { + public CanvasAvailabilityStatus Status { get; init; } } public sealed record CanvasNotLoadedAvailabilityState { + public CanvasAvailabilityStatus Status { get; init; } } public sealed record CanvasLoadingAvailabilityState { + public CanvasAvailabilityStatus Status { get; init; } } public sealed record CanvasEmptyAvailabilityState { + public CanvasAvailabilityStatus Status { get; init; } } public sealed record CanvasReadyAvailabilityState { + public CanvasAvailabilityStatus Status { get; init; } + /// Actions currently declared by the live provider (full replacement each time this state is produced). public required List Actions { get; init; } } public sealed record CanvasFailedAvailabilityState { + public CanvasAvailabilityStatus Status { get; init; } + /// Stable machine-readable and human-readable failure information. public required ErrorInfo Error { get; init; } } @@ -6798,6 +6814,9 @@ public sealed class SnapshotState /// Automation run state variant, when populated. public AutomationRunState? AutomationRun { get; set; } + + /// Canvas state variant, when populated. + public CanvasState? Canvas { get; set; } } /// System.Text.Json converter for the SnapshotState shape-probed union. @@ -6808,7 +6827,13 @@ public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert using var doc = JsonDocument.ParseValue(ref reader); var root = doc.RootElement; var result = new SnapshotState(); - if (root.TryGetProperty("automation", out _) && + if (root.TryGetProperty("identity", out _) && + root.TryGetProperty("availability", out _) && + root.TryGetProperty("revision", out _)) + { + result.Canvas = root.Deserialize(AhpJsonTypeInfo.Get(options)); + } + else if (root.TryGetProperty("automation", out _) && root.TryGetProperty("origin", out _) && root.TryGetProperty("sessions", out _)) { @@ -6854,6 +6879,7 @@ public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) { + if (value.Canvas is not null) { JsonSerializer.Serialize(writer, value.Canvas, AhpJsonTypeInfo.Get(options)); return; } if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 0418dbbb..1f8c3437 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,11 +14,10 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "0.10.0"; + public const string Current = "0.9.0"; private static readonly string[] s_supported = { - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index f132f9d8..95cd881c 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -30,6 +30,49 @@ public enum ReduceOutcome /// public static class Reducers { + /// Applies newer canvas revisions without allowing stale state or incarnation changes. + public static ReduceOutcome ApplyToCanvas(CanvasState state, StateAction action) + { + switch (action.Value) + { + case CanvasAvailabilityChangedAction a: + if (a.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + state.Availability = a.Availability; + state.Revision = a.Revision; + break; + case CanvasTrustChangedAction a: + if (a.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + state.Trust = a.Trust; + state.Revision = a.Revision; + break; + case CanvasIncarnationChangedAction a: + if (a.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + state.Identity = state.Identity with { Incarnation = a.Incarnation }; + state.Revision = a.Revision; + break; + case CanvasTitleChangedAction a: + if (a.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + state.Title = a.Title; + state.Revision = a.Revision; + break; + default: + return ReduceOutcome.OutOfScope; + } + return ReduceOutcome.Applied; + } + // ─── Injectable timestamp ────────────────────────────────────────────── private static volatile Func s_now = () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); @@ -693,6 +736,34 @@ public static ReduceOutcome ApplyToSession(SessionState state, StateAction actio case SessionChangesetsChangedAction a: state.Changesets = CopyList(a.Changesets); return ReduceOutcome.Applied; + case SessionCanvasSetAction a: + { + state.Canvases ??= new List(); + int idx = state.Canvases.FindIndex(c => c.Resource == a.Canvas.Resource); + if (idx < 0) + { + state.Canvases.Add(a.Canvas); + } + else + { + if (a.Canvas.Revision <= state.Canvases[idx].Revision) + { + return ReduceOutcome.NoOp; + } + state.Canvases[idx] = a.Canvas; + } + return ReduceOutcome.Applied; + } + case SessionCanvasRemovedAction a: + { + int idx = state.Canvases?.FindIndex(c => c.Resource == a.Resource) ?? -1; + if (idx < 0) + { + return ReduceOutcome.NoOp; + } + state.Canvases!.RemoveAt(idx); + return ReduceOutcome.Applied; + } case SessionConfigChangedAction a: if (state.Config is null) { diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs index 4bf9390e..1f6b2d11 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs @@ -92,6 +92,9 @@ public void ReducerMatchesFixture(string name, string path) actions, Reducers.ApplyToAutomationRun); break; + case "canvas": + RunFixture(initial, expected, actions, Reducers.ApplyToCanvas); + break; default: throw new Xunit.Sdk.XunitException($"unknown reducer kind '{reducer}'"); } diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 6da0af86..ff673316 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -881,6 +881,26 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct state.Changesets = append([]ahptypes.Changeset(nil), a.Changesets...) } return ReduceOutcomeApplied + case *ahptypes.SessionCanvasSetAction: + for i := range state.Canvases { + if state.Canvases[i].Resource == a.Canvas.Resource { + if a.Canvas.Revision <= state.Canvases[i].Revision { + return ReduceOutcomeNoOp + } + state.Canvases[i] = a.Canvas + return ReduceOutcomeApplied + } + } + state.Canvases = append(state.Canvases, a.Canvas) + return ReduceOutcomeApplied + case *ahptypes.SessionCanvasRemovedAction: + for i := range state.Canvases { + if state.Canvases[i].Resource == a.Resource { + state.Canvases = append(state.Canvases[:i], state.Canvases[i+1:]...) + return ReduceOutcomeApplied + } + } + return ReduceOutcomeNoOp case *ahptypes.SessionConfigChangedAction: if state.Config == nil { return ReduceOutcomeNoOp @@ -1853,6 +1873,39 @@ func ApplyActionToResourceWatch(state *ahptypes.ResourceWatchState, action ahpty return ReduceOutcomeOutOfScope } +// ApplyActionToCanvas applies a newer revision without allowing stale state or incarnation changes. +func ApplyActionToCanvas(state *ahptypes.CanvasState, action ahptypes.StateAction) ReduceOutcome { + switch a := action.Value.(type) { + case *ahptypes.CanvasAvailabilityChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Availability = a.Availability + state.Revision = a.Revision + case *ahptypes.CanvasTrustChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Trust = a.Trust + state.Revision = a.Revision + case *ahptypes.CanvasIncarnationChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Identity.Incarnation = a.Incarnation + state.Revision = a.Revision + case *ahptypes.CanvasTitleChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Title = a.Title + state.Revision = a.Revision + default: + return ReduceOutcomeOutOfScope + } + return ReduceOutcomeApplied +} + // ApplyActionToAutomation applies an action to automation catalogue state. func ApplyActionToAutomation(state *ahptypes.AutomationState, action ahptypes.StateAction) ReduceOutcome { switch a := action.Value.(type) { diff --git a/clients/go/ahp/reducers_fixture_test.go b/clients/go/ahp/reducers_fixture_test.go index a1617d4b..be4b5636 100644 --- a/clients/go/ahp/reducers_fixture_test.go +++ b/clients/go/ahp/reducers_fixture_test.go @@ -157,6 +157,8 @@ func TestFixtureDrivenReducerParity(t *testing.T) { runFixture[ahptypes.AutomationState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAutomation) case "automationRun": runFixture[ahptypes.AutomationRunState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAutomationRun) + case "canvas": + runFixture[ahptypes.CanvasState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToCanvas) default: tt.Fatalf("unknown reducer kind %q", fixture.Reducer) } diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 5ec00f44..2bcb48cb 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -189,7 +189,7 @@ type InitializeResult struct { // {@link ClientCapabilities.canvases} declared. // // **Protocol version support alone is not a runtime capability**: a host - // speaking protocol `>= 0.10.0` without this field present MUST NOT be + // speaking a supported protocol version without this field present MUST NOT be // assumed to have a usable canvas runtime. This field — not the // negotiated `protocolVersion` — is the authoritative signal, and is // independent of any individual canvas's live availability @@ -228,7 +228,7 @@ type ClientCapabilities struct { // {@link CanvasTrustStatus}, which is a separate, host-owned decision. // // This declares only the CLIENT's rendering capability. Protocol version - // support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + // support alone is not evidence that the SERVER // actually has a working canvas runtime — see // {@link InitializeResult.canvases}, the server-side counterpart, which a // client MUST also check before treating canvases as usable. diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index e5738215..35edaadf 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1022,6 +1022,19 @@ type SessionState struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// MarshalJSON preserves an explicitly empty canvas catalogue. +func (s SessionState) MarshalJSON() ([]byte, error) { + type wire SessionState + var canvases *[]CanvasEntry + if s.Canvases != nil { + canvases = &s.Canvases + } + return json.Marshal(struct { + wire + Canvases *[]CanvasEntry `json:"canvases,omitempty"` + }{wire(s), canvases}) +} + // A client currently providing tools and interactive capabilities to a session. // // A session MAY have several active clients at once; entries in @@ -6487,11 +6500,14 @@ type SnapshotState struct { Annotations *AnnotationsState `json:"-"` Automations *AutomationState `json:"-"` AutomationRun *AutomationRunState `json:"-"` + Canvas *CanvasState `json:"-"` } // MarshalJSON encodes whichever variant is currently populated. func (s SnapshotState) MarshalJSON() ([]byte, error) { switch { + case s.Canvas != nil: + return json.Marshal(s.Canvas) case s.AutomationRun != nil: return json.Marshal(s.AutomationRun) case s.Automations != nil: @@ -6524,6 +6540,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { return err } switch { + case containsAll(probe, "identity", "availability", "revision"): + var v CanvasState + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Canvas = &v case containsAll(probe, "automation", "origin", "sessions"): var v AutomationRunState if err := json.Unmarshal(data, &v); err != nil { diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 546b8583..456d539c 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -6,13 +6,12 @@ package ahptypes // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.10.0" +const ProtocolVersion = "0.9.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 3f1b9a89..4faee915 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,7 +2,6 @@ "client": "go", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index 63e46946..bdf2a488 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,7 +2,6 @@ "client": "kotlin", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 87c7493f..1e841e6b 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -84,6 +84,12 @@ public object AutomationRunReducer : Reducer { automationRunReducer(state, action) } +/** Pure canvas reducer as a [Reducer] instance. */ +public object CanvasReducer : Reducer { + override fun reduce(state: CanvasState, action: StateAction): CanvasState = + canvasReducer(state, action) +} + private val isoTimestampFormatter = DateTimeFormatterBuilder().appendInstant(3).toFormatter() private fun addMillisecondsToTimestamp(timestamp: String, duration: Long): String = @@ -613,6 +619,33 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat is StateActionSessionChangesetsChanged -> state.copy(changesets = action.value.changesets) + is StateActionSessionCanvasSet -> { + val canvas = action.value.canvas + val list = state.canvases ?: emptyList() + val idx = list.indexOfFirst { it.resource == canvas.resource } + if (idx < 0) { + state.copy(canvases = list + canvas) + } else if (canvas.revision <= list[idx].revision) { + state + } else { + val updated = list.toMutableList() + updated[idx] = canvas + state.copy(canvases = updated) + } + } + + is StateActionSessionCanvasRemoved -> { + val list = state.canvases + val idx = list?.indexOfFirst { it.resource == action.value.resource } ?: -1 + if (list == null || idx < 0) { + state + } else { + val updated = list.toMutableList() + updated.removeAt(idx) + state.copy(canvases = updated) + } + } + is StateActionSessionConfigChanged -> { val a = action.value val config = state.config @@ -1846,6 +1879,23 @@ public fun resourceWatchReducer(state: ResourceWatchState, action: StateAction): else -> state } +/** Applies only newer canvas revisions, preserving the opaque incarnation on stale actions. */ +public fun canvasReducer(state: CanvasState, action: StateAction): CanvasState = when (action) { + is StateActionCanvasAvailabilityChanged -> + if (action.value.revision <= state.revision) state + else state.copy(availability = action.value.availability, revision = action.value.revision) + is StateActionCanvasTrustChanged -> + if (action.value.revision <= state.revision) state + else state.copy(trust = action.value.trust, revision = action.value.revision) + is StateActionCanvasIncarnationChanged -> + if (action.value.revision <= state.revision) state + else state.copy(identity = state.identity.copy(incarnation = action.value.incarnation), revision = action.value.revision) + is StateActionCanvasTitleChanged -> + if (action.value.revision <= state.revision) state + else state.copy(title = action.value.title, revision = action.value.revision) + else -> state +} + // ─── Automation Reducer ───────────────────────────────────────────────────── /** Pure reducer for [AutomationState]. Handles automation-channel action variants. */ diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 4ef0b05e..45ad09e2 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -401,7 +401,7 @@ data class InitializeResult( * {@link ClientCapabilities.canvases} declared. * * **Protocol version support alone is not a runtime capability**: a host - * speaking protocol `>= 0.10.0` without this field present MUST NOT be + * speaking a supported protocol version without this field present MUST NOT be * assumed to have a usable canvas runtime. This field — not the * negotiated `protocolVersion` — is the authoritative signal, and is * independent of any individual canvas's live availability @@ -440,7 +440,7 @@ data class ClientCapabilities( * {@link CanvasTrustStatus}, which is a separate, host-owned decision. * * This declares only the CLIENT's rendering capability. Protocol version - * support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + * support alone is not evidence that the SERVER * actually has a working canvas runtime — see * {@link InitializeResult.canvases}, the server-side counterpart, which a * client MUST also check before treating canvases as usable. diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index c33e14e8..c48595c6 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -7625,6 +7625,7 @@ sealed interface SnapshotState { @JvmInline value class Annotations(val value: AnnotationsState) : SnapshotState @JvmInline value class Automations(val value: AutomationState) : SnapshotState @JvmInline value class AutomationRun(val value: AutomationRunState) : SnapshotState + @JvmInline value class Canvas(val value: CanvasState) : SnapshotState } internal object SnapshotStateSerializer : KSerializer { @@ -7647,6 +7648,8 @@ internal object SnapshotStateSerializer : KSerializer { // key); TerminalState has required `content`; RootState is the // catch-all. return when { + obj.containsKey("identity") && obj.containsKey("availability") && obj.containsKey("revision") -> + SnapshotState.Canvas(input.json.decodeFromJsonElement(CanvasState.serializer(), element)) obj.containsKey("automation") && obj.containsKey("origin") && obj.containsKey("sessions") -> SnapshotState.AutomationRun(input.json.decodeFromJsonElement(AutomationRunState.serializer(), element)) obj.containsKey("entries") -> @@ -7678,6 +7681,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Annotations -> output.json.encodeToJsonElement(AnnotationsState.serializer(), value.value) is SnapshotState.Automations -> output.json.encodeToJsonElement(AutomationState.serializer(), value.value) is SnapshotState.AutomationRun -> output.json.encodeToJsonElement(AutomationRunState.serializer(), value.value) + is SnapshotState.Canvas -> output.json.encodeToJsonElement(CanvasState.serializer(), value.value) } output.encodeJsonElement(element) } diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index d4da3055..31c2b348 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.10.0" +public const val PROTOCOL_VERSION: String = "0.9.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,7 +16,6 @@ public const val PROTOCOL_VERSION: String = "0.10.0" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt index c3f4675e..0ad5e840 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt @@ -5,6 +5,7 @@ import com.microsoft.agenthostprotocol.generated.ChangesetState import com.microsoft.agenthostprotocol.generated.AnnotationsState import com.microsoft.agenthostprotocol.generated.AutomationState import com.microsoft.agenthostprotocol.generated.AutomationRunState +import com.microsoft.agenthostprotocol.generated.CanvasState import com.microsoft.agenthostprotocol.generated.ResourceWatchState import com.microsoft.agenthostprotocol.generated.RootState import com.microsoft.agenthostprotocol.generated.SessionState @@ -224,6 +225,14 @@ class FixtureDrivenReducerTest { }, ) + "canvas" -> compareFixture( + file = file, + initial = initial, + expected = expected, + serializer = CanvasState.serializer(), + run = { state -> actions.fold(state, ::canvasReducer) }, + ) + else -> fail("${file.name}: unsupported reducer '$reducer'") } } diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index bae2a3af..83b7ce3a 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -15,9 +15,10 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, - Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, - ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, - ChatSummary, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, + CanvasAvailabilityState, CanvasEntry, CanvasTrustState, Changeset, ChangesetFile, + ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, + ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, + ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index f5d3629d..e218fe76 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -16,9 +16,11 @@ use crate::actions::{ActionEnvelope, StateAction}; #[allow(unused_imports)] use crate::state::{ AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, - AutomationTrigger, AutomationTriggerDefinition, ContentRef, Message, MessageAttachment, - ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, - Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn, + AutomationTrigger, AutomationTriggerDefinition, CanvasAvailabilityStatus, CanvasEntry, + CanvasIdentityKey, CanvasSourcePresentation, CanvasTypeDeclaration, ContentRef, Icon, Message, + MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, + SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, + Turn, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -301,7 +303,7 @@ pub struct InitializeResult { /// {@link ClientCapabilities.canvases} declared. /// /// **Protocol version support alone is not a runtime capability**: a host - /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// speaking a supported protocol version without this field present MUST NOT be /// assumed to have a usable canvas runtime. This field — not the /// negotiated `protocolVersion` — is the authoritative signal, and is /// independent of any individual canvas's live availability @@ -344,7 +346,7 @@ pub struct ClientCapabilities { /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. /// /// This declares only the CLIENT's rendering capability. Protocol version - /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// support alone is not evidence that the SERVER /// actually has a working canvas runtime — see /// {@link InitializeResult.canvases}, the server-side counterpart, which a /// client MUST also check before treating canvases as usable. diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 14f52e61..8e906b0d 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -6619,6 +6619,7 @@ pub enum CanvasAvailabilityState { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum SnapshotState { + Canvas(Box), Session(Box), Chat(Box), Terminal(Box), diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 042f4492..15cc81d3 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.10.0"; +pub const PROTOCOL_VERSION: &str = "0.9.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,6 +13,5 @@ pub const PROTOCOL_VERSION: &str = "0.10.0"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1", -]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = + &["0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1"]; diff --git a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs index c581bd26..13b1dbab 100644 --- a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs @@ -39,14 +39,15 @@ use std::collections::HashMap; use ahp_types::actions::ActionEnvelope; use ahp_types::common::ROOT_RESOURCE_URI; use ahp_types::state::{ - AnnotationsState, AutomationEntry, AutomationRunState, AutomationState, ChangesetState, - ChatState, ResourceWatchState, RootState, SessionState, SnapshotState, TerminalState, + AnnotationsState, AutomationEntry, AutomationRunState, AutomationState, CanvasState, + ChangesetState, ChatState, ResourceWatchState, RootState, SessionState, SnapshotState, + TerminalState, }; use crate::hosts::{HostId, HostSubscriptionEvent}; use crate::reducers::{ - apply_action_to_automation, apply_action_to_automation_run, apply_action_to_chat, - apply_action_to_root, apply_action_to_session, apply_action_to_terminal, + apply_action_to_automation, apply_action_to_automation_run, apply_action_to_canvas, + apply_action_to_chat, apply_action_to_root, apply_action_to_session, apply_action_to_terminal, }; use crate::SubscriptionEvent; @@ -100,6 +101,7 @@ pub struct MultiHostStateMirror { automation_catalogs: HashMap, automations: HashMap, automation_runs: HashMap, + canvases: HashMap, } impl MultiHostStateMirror { @@ -158,6 +160,11 @@ impl MultiHostStateMirror { &self.automation_runs } + /// Borrow canvas states keyed by `(host_id, uri)`. + pub fn canvases(&self) -> &HashMap { + &self.canvases + } + /// Convenience: apply a [`HostSubscriptionEvent`] produced by /// [`crate::hosts::MultiHostClient::events`]. Action envelopes are /// routed through the reducer; non-action events (session-summary @@ -214,6 +221,9 @@ impl MultiHostStateMirror { if let Some(run) = self.automation_runs.get_mut(&key) { apply_action_to_automation_run(run, &envelope.action); } + if let Some(canvas) = self.canvases.get_mut(&key) { + apply_action_to_canvas(canvas, &envelope.action); + } // Changesets are seeded by `apply_snapshot` only — there's no // changeset reducer in the SDK today (matching the Swift // mirror's behavior). Fall through silently. @@ -256,6 +266,9 @@ impl MultiHostStateMirror { SnapshotState::AutomationRun(state) => { self.automation_runs.insert(key, state.as_ref().clone()); } + SnapshotState::Canvas(state) => { + self.canvases.insert(key, state.as_ref().clone()); + } } } @@ -273,6 +286,7 @@ impl MultiHostStateMirror { self.automation_catalogs.remove(host); self.automations.retain(|key, _| &key.host_id != host); self.automation_runs.retain(|key, _| &key.host_id != host); + self.canvases.retain(|key, _| &key.host_id != host); } /// Drop every host's state. @@ -287,6 +301,7 @@ impl MultiHostStateMirror { self.automation_catalogs.clear(); self.automations.clear(); self.automation_runs.clear(); + self.canvases.clear(); } fn replace_automations(&mut self, host: &HostId, automations: Vec) { diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 95b02ff4..a3f7532a 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -57,14 +57,14 @@ use ahp_types::actions::{ ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, ChangesetOperationStatus, - ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, - ConfirmationOption, Customization, CustomizationEnablement, ErrorResponsePart, - InputRequestResponsePart, McpServerStartingState, McpServerState, McpServerStoppedState, - PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, - SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, - TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, TerminalState, - TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, + ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, CanvasState, + ChangesetOperationStatus, ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, + ChildCustomization, ConfirmationOption, Customization, CustomizationEnablement, + ErrorResponsePart, InputRequestResponsePart, McpServerStartingState, McpServerState, + McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, + RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + TerminalCommandPart, TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, + TerminalState, TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, ToolCallCancelledState, ToolCallCompletedState, ToolCallConfirmationReason, ToolCallContributor, ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStatus, @@ -112,6 +112,42 @@ pub enum ReduceError { }, } +/// Applies newer canvas revisions without allowing stale state or incarnation changes. +pub fn apply_action_to_canvas(state: &mut CanvasState, action: &StateAction) -> ReduceOutcome { + match action { + StateAction::CanvasAvailabilityChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.availability = a.availability.clone(); + state.revision = a.revision; + } + StateAction::CanvasTrustChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.trust = a.trust.clone(); + state.revision = a.revision; + } + StateAction::CanvasIncarnationChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.identity.incarnation = a.incarnation.clone(); + state.revision = a.revision; + } + StateAction::CanvasTitleChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.title = a.title.clone(); + state.revision = a.revision; + } + _ => return ReduceOutcome::OutOfScope, + } + ReduceOutcome::Applied +} + fn add_milliseconds_to_timestamp(timestamp: &str, duration: i64) -> Result { let start: Timestamp = timestamp @@ -773,6 +809,31 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - state.changesets = a.changesets.clone(); ReduceOutcome::Applied } + StateAction::SessionCanvasSet(a) => { + let list = state.canvases.get_or_insert_with(Vec::new); + if let Some(idx) = list + .iter() + .position(|canvas| canvas.resource == a.canvas.resource) + { + if a.canvas.revision <= list[idx].revision { + return ReduceOutcome::NoOp; + } + list[idx] = a.canvas.clone(); + } else { + list.push(a.canvas.clone()); + } + ReduceOutcome::Applied + } + StateAction::SessionCanvasRemoved(a) => { + let Some(list) = state.canvases.as_mut() else { + return ReduceOutcome::NoOp; + }; + let Some(idx) = list.iter().position(|canvas| canvas.resource == a.resource) else { + return ReduceOutcome::NoOp; + }; + list.remove(idx); + ReduceOutcome::Applied + } StateAction::SessionConfigChanged(a) => { let Some(config) = state.config.as_mut() else { return ReduceOutcome::NoOp; @@ -2184,6 +2245,7 @@ mod tests { config: None, customizations: None, changesets: None, + canvases: None, input_needed: None, meta: None, } @@ -2663,6 +2725,14 @@ mod tests { &file_name, description, ), + "canvas" => run_fixture::( + initial, + expected, + &parsed_actions, + apply_action_to_canvas, + &file_name, + description, + ), other => { panic!("{file_name}: unknown reducer type '{other}'"); } diff --git a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs index 4dd42c91..309d8edb 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -11,8 +11,8 @@ use ahp::hosts::{HostId, HostSubscriptionEvent}; use ahp::{HostedResourceKey, MultiHostStateMirror, SubscriptionEvent}; use ahp_types::actions::{ - ActionEnvelope, RootActiveSessionsChangedAction, RootAgentsChangedAction, - SessionTitleChangedAction, StateAction, + ActionEnvelope, CanvasIncarnationChangedAction, RootActiveSessionsChangedAction, + RootAgentsChangedAction, SessionTitleChangedAction, StateAction, }; use ahp_types::common::ROOT_RESOURCE_URI; use ahp_types::state::{ @@ -20,6 +20,53 @@ use ahp_types::state::{ SnapshotState, }; +#[test] +fn canvas_snapshots_and_revisions_are_isolated_per_host_and_reset() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../types/test-cases/round-trips/050-canvas-snapshot.json" + )) + .unwrap(); + let snapshot: Snapshot = serde_json::from_value(fixture["input"].clone()).unwrap(); + let alpha = HostId::from("alpha"); + let beta = HostId::from("beta"); + let mut mirror = MultiHostStateMirror::new(); + mirror.apply_snapshot(&alpha, &snapshot); + mirror.apply_snapshot(&beta, &snapshot); + for (revision, incarnation) in [(5, "generation-two"), (4, "stale")] { + mirror.apply_envelope( + &alpha, + &ActionEnvelope { + channel: snapshot.resource.clone(), + action: StateAction::CanvasIncarnationChanged(CanvasIncarnationChangedAction { + revision, + incarnation: incarnation.into(), + }), + server_seq: 9, + origin: None, + rejection_reason: None, + }, + ); + } + let alpha_key = HostedResourceKey::new(alpha.clone(), snapshot.resource.clone()); + let beta_key = HostedResourceKey::new(beta.clone(), snapshot.resource.clone()); + assert_eq!( + ( + mirror.canvases()[&alpha_key].revision, + mirror.canvases()[&alpha_key].identity.incarnation.as_str(), + mirror.canvases()[&beta_key].revision, + mirror.canvases()[&beta_key].identity.incarnation.as_str() + ), + (5, "generation-two", 4, "generation-one"), + ); + mirror.reset_host(&alpha); + assert_eq!( + mirror.canvases().keys().collect::>(), + vec![&beta_key] + ); + mirror.reset(); + assert!(mirror.canvases().is_empty()); +} + fn agent(provider: &str) -> AgentInfo { AgentInfo { provider: provider.into(), @@ -65,6 +112,7 @@ fn session_state(title: &str, _resource: &str) -> SessionState { config: None, customizations: None, changesets: None, + canvases: None, input_needed: None, meta: None, } diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index a756c2ac..b93f3893 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,7 +2,6 @@ "client": "rust", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 541284c8..d43727b4 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -350,7 +350,7 @@ public struct InitializeResult: Codable, Sendable { /// {@link ClientCapabilities.canvases} declared. /// /// **Protocol version support alone is not a runtime capability**: a host - /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// speaking a supported protocol version without this field present MUST NOT be /// assumed to have a usable canvas runtime. This field — not the /// negotiated `protocolVersion` — is the authoritative signal, and is /// independent of any individual canvas's live availability @@ -424,7 +424,7 @@ public struct ClientCapabilities: Codable, Sendable { /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. /// /// This declares only the CLIENT's rendering capability. Protocol version - /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// support alone is not evidence that the SERVER /// actually has a working canvas runtime — see /// {@link InitializeResult.canvases}, the server-side counterpart, which a /// client MUST also check before treating canvases as usable. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index eff12027..620d6b7c 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -8229,7 +8229,7 @@ public enum AutomationRunLifecycle: Codable, Sendable { } public enum CanvasSource: Codable, Sendable { - case extension(CanvasExtensionSource) + case `extension`(CanvasExtensionSource) case package(CanvasPackageSource) /// Unknown or future discriminant; the raw payload is preserved /// and re-encoded verbatim for forward-compatibility. @@ -8247,7 +8247,7 @@ public enum CanvasSource: Codable, Sendable { } switch discriminant { case "extension": - self = .extension(try CanvasExtensionSource(from: decoder)) + self = .`extension`(try CanvasExtensionSource(from: decoder)) case "package": self = .package(try CanvasPackageSource(from: decoder)) default: @@ -8257,8 +8257,8 @@ public enum CanvasSource: Codable, Sendable { public func encode(to encoder: Encoder) throws { switch self { - case .extension(var value): - value.kind = .extension + case .`extension`(var value): + value.kind = .`extension` try value.encode(to: encoder) case .package(var value): value.kind = .package @@ -8444,13 +8444,16 @@ public enum SnapshotState: Codable, Sendable { case annotations(AnnotationsState) case automations(AutomationState) case automationRun(AutomationRunState) + case canvas(CanvasState) public init(from decoder: Decoder) throws { // Try the most distinctive shapes first. SessionState has required // `lifecycle` / `activeClients` / `chats`; ChatState has required // `turns`; the remaining variants follow, with RootState as the // catch-all. - if let session = try? SessionState(from: decoder) { + if let canvas = try? CanvasState(from: decoder) { + self = .canvas(canvas) + } else if let session = try? SessionState(from: decoder) { self = .session(session) } else if let chat = try? ChatState(from: decoder) { self = .chat(chat) @@ -8482,6 +8485,7 @@ public enum SnapshotState: Codable, Sendable { case .annotations(let state): try state.encode(to: encoder) case .automations(let state): try state.encode(to: encoder) case .automationRun(let state): try state.encode(to: encoder) + case .canvas(let state): try state.encode(to: encoder) } } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index 35825e38..b8527850 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.10.0" +public let PROTOCOL_VERSION: String = "0.9.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first. The first entry equals @@ -13,7 +13,6 @@ public let PROTOCOL_VERSION: String = "0.10.0" /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift index eac2ea15..bb2e5cdc 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift @@ -110,6 +110,18 @@ public struct AHPRootReducer: Reducer { } +/// Protocol-based canvas reducer. +public struct AHPCanvasReducer: Reducer { + public typealias State = CanvasState + public typealias Action = StateAction + + public init() {} + + public func reduce(into state: inout CanvasState, action: StateAction) { + state = canvasReducer(state: state, action: action) + } +} + // MARK: - Chat Reducer (Protocol-based) /// Protocol-based chat reducer for AHP chat state. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 0d5585cf..b31b2074 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -83,6 +83,32 @@ private func sessionInputRequestID(_ r: SessionInputRequest) -> String? { } } +/// Applies newer canvas revisions without allowing stale state or incarnation changes. +public func canvasReducer(state: CanvasState, action: StateAction) -> CanvasState { + var next = state + switch action { + case .canvasAvailabilityChanged(let a): + guard a.revision > state.revision else { return state } + next.availability = a.availability + next.revision = a.revision + case .canvasTrustChanged(let a): + guard a.revision > state.revision else { return state } + next.trust = a.trust + next.revision = a.revision + case .canvasIncarnationChanged(let a): + guard a.revision > state.revision else { return state } + next.identity.incarnation = a.incarnation + next.revision = a.revision + case .canvasTitleChanged(let a): + guard a.revision > state.revision else { return state } + next.title = a.title + next.revision = a.revision + default: + return state + } + return next +} + // MARK: - Root Reducer /// Pure reducer for root state. @@ -760,6 +786,24 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS next.changesets = a.changesets return next + case .sessionCanvasSet(let a): + var canvases = state.canvases ?? [] + if let idx = canvases.firstIndex(where: { $0.resource == a.canvas.resource }) { + guard a.canvas.revision > canvases[idx].revision else { return state } + canvases[idx] = a.canvas + } else { + canvases.append(a.canvas) + } + var next = state + next.canvases = canvases + return next + + case .sessionCanvasRemoved(let a): + guard let idx = state.canvases?.firstIndex(where: { $0.resource == a.resource }) else { return state } + var next = state + next.canvases?.remove(at: idx) + return next + case .sessionConfigChanged(let a): guard var config = state.config else { return state } config.values = a.replace == true ? a.config : config.values.merging(a.config) { _, new in new } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift index 27168493..1901644f 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift @@ -21,6 +21,7 @@ public actor AHPStateMirror { public private(set) var automationCatalog = AutomationState(entries: []) public private(set) var automations: [String: AutomationEntry] = [:] public private(set) var automationRuns: [String: AutomationRunState] = [:] + public private(set) var canvases: [String: CanvasState] = [:] public init() {} @@ -78,6 +79,9 @@ public actor AHPStateMirror { automationRuns[channel] = run return } + if let canvas = canvases[channel] { + canvases[channel] = canvasReducer(state: canvas, action: action) + } } /// Seed the mirror from a `Snapshot`, routing by its `state` discriminator. @@ -102,6 +106,8 @@ public actor AHPStateMirror { rebuildAutomationIndex() case .automationRun(let state): automationRuns[snapshot.resource] = state + case .canvas(let state): + canvases[snapshot.resource] = state } } @@ -125,5 +131,6 @@ public actor AHPStateMirror { automationCatalog = AutomationState(entries: []) automations.removeAll() automationRuns.removeAll() + canvases.removeAll() } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift index e08fd401..4b665f60 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift @@ -52,6 +52,7 @@ public actor MultiHostStateMirror { public private(set) var automationCatalogs: [HostId: AutomationState] = [:] public private(set) var automations: [HostedResourceKey: AutomationEntry] = [:] public private(set) var automationRuns: [HostedResourceKey: AutomationRunState] = [:] + public private(set) var canvases: [HostedResourceKey: CanvasState] = [:] public init() {} @@ -118,6 +119,10 @@ public actor MultiHostStateMirror { automationRuns[key] = run return } + if let canvas = canvases[key] { + canvases[key] = canvasReducer(state: canvas, action: action) + return + } // No state for this `(host, channel)` yet — the reducer can't // initialise one; only `applySnapshot(host:snapshot:)` can. } @@ -145,6 +150,8 @@ public actor MultiHostStateMirror { setAutomationCatalog(host: host, catalog: state) case .automationRun(let state): automationRuns[key] = state + case .canvas(let state): + canvases[key] = state } } @@ -160,6 +167,7 @@ public actor MultiHostStateMirror { automationCatalogs.removeValue(forKey: host) automations = automations.filter { $0.key.hostId != host } automationRuns = automationRuns.filter { $0.key.hostId != host } + canvases = canvases.filter { $0.key.hostId != host } } /// Reset every host's state. @@ -174,6 +182,7 @@ public actor MultiHostStateMirror { automationCatalogs.removeAll() automations.removeAll() automationRuns.removeAll() + canvases.removeAll() } private func setAutomationCatalog(host: HostId, catalog: AutomationState) { diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift index 7c6d46aa..8cd9a548 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift @@ -4,8 +4,38 @@ import XCTest import AgentHostProtocol @testable import AgentHostProtocolClient +func canvasFixtureSnapshot() throws -> Snapshot { + struct Fixture: Decodable { + let input: Snapshot + } + let root = (0..<6).reduce(URL(fileURLWithPath: #filePath)) { url, _ in url.deletingLastPathComponent() } + let data = try Data(contentsOf: root.appendingPathComponent("types/test-cases/round-trips/050-canvas-snapshot.json")) + return try JSONDecoder().decode(Fixture.self, from: data).input +} + final class AHPStateMirrorTests: XCTestCase { + func testCanvasSnapshotRetainsOnlyNewerIncarnationsAndResets() async throws { + let mirror = AHPStateMirror() + let snapshot = try canvasFixtureSnapshot() + await mirror.applySnapshot(snapshot) + for (revision, incarnation) in [(5, "generation-two"), (4, "stale")] { + await mirror.apply(ActionEnvelope( + channel: snapshot.resource, + action: .canvasIncarnationChanged(CanvasIncarnationChangedAction( + type: .canvasIncarnationChanged, incarnation: incarnation, revision: revision + )), + serverSeq: 9 + )) + } + let canvases = await mirror.canvases + XCTAssertEqual(canvases[snapshot.resource]?.identity.incarnation, "generation-two") + XCTAssertEqual(canvases[snapshot.resource]?.revision, 5) + await mirror.reset() + let empty = await mirror.canvases + XCTAssertTrue(empty.isEmpty) + } + func testApplySnapshotSeedsRootState() async { let mirror = AHPStateMirror() let agents = [ diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift index 218f9adf..f400c7f1 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift @@ -6,6 +6,28 @@ import AgentHostProtocol final class MultiHostStateMirrorTests: XCTestCase { + func testCanvasSnapshotsAndActionsRemainIsolatedPerHost() async throws { + let mirror = MultiHostStateMirror() + let snapshot = try canvasFixtureSnapshot() + await mirror.applySnapshot(host: "alpha", snapshot: snapshot) + await mirror.applySnapshot(host: "beta", snapshot: snapshot) + await mirror.apply(host: "alpha", envelope: ActionEnvelope( + channel: snapshot.resource, + action: .canvasTitleChanged(CanvasTitleChangedAction(type: .canvasTitleChanged, title: "Alpha", revision: 5)), + serverSeq: 9 + )) + let alpha = HostedResourceKey(hostId: "alpha", uri: snapshot.resource) + let beta = HostedResourceKey(hostId: "beta", uri: snapshot.resource) + let canvases = await mirror.canvases + XCTAssertEqual([canvases[alpha]?.title, canvases[beta]?.title], ["Alpha", "Widget"]) + await mirror.reset(host: "alpha") + let remaining = await mirror.canvases + XCTAssertEqual(Array(remaining.keys), [beta]) + await mirror.reset() + let empty = await mirror.canvases + XCTAssertTrue(empty.isEmpty) + } + // MARK: - root_states_are_isolated_per_host func testRootStatesAreIsolatedPerHost() async { diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift index 100ef243..af66bba4 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift @@ -204,6 +204,10 @@ final class FixtureDrivenReducerTests: XCTestCase { try compareFixture(file: file, fixture: fixture, stateType: AutomationRunState.self) { state in actions.reduce(state) { automationRunReducer(state: $0, action: $1) } } + case "canvas": + try compareFixture(file: file, fixture: fixture, stateType: CanvasState.self) { state in + actions.reduce(state) { canvasReducer(state: $0, action: $1) } + } default: throw FixtureError.unsupportedReducer(fixture.reducer) } diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index 485b6ec3..e5693c0d 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,7 +2,6 @@ "client": "swift", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index c7d90aa5..1925c929 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,7 +2,6 @@ "client": "typescript", "packageVersion": "0.9.0", "supportedProtocolVersions": [ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/docs/.changes/20260913-canvas-client-parity.json b/docs/.changes/20260913-canvas-client-parity.json new file mode 100644 index 00000000..f4ca9d64 --- /dev/null +++ b/docs/.changes/20260913-canvas-client-parity.json @@ -0,0 +1,5 @@ +{ + "type": "fixed", + "message": "Preserve canvas snapshots and empty catalogues in native clients, apply revision-checked canvas and session catalogue actions, and isolate canvas mirrors by host.", + "targets": ["rust", "kotlin", "swift", "go", "dotnet"] +} diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 42ae646c..10d44ef7 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -132,7 +132,7 @@ "canvases": { "type": "object", "additionalProperties": {}, - "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." + "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." } } }, @@ -189,7 +189,7 @@ }, "canvases": { "$ref": "#/$defs/CanvasCapabilities", - "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking protocol `>= 0.10.0` without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." + "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking a supported protocol version without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." } }, "required": [ diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 42cdbc22..f7a0e339 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -6250,7 +6250,7 @@ "canvases": { "type": "object", "additionalProperties": {}, - "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." + "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." } } }, @@ -6307,7 +6307,7 @@ }, "canvases": { "$ref": "#/$defs/CanvasCapabilities", - "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking protocol `>= 0.10.0` without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." + "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking a supported protocol version without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." } }, "required": [ diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index ceffbd59..8bf53a41 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -804,20 +804,20 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'AutomationCancelledRunLifecycle' }, { name: 'AutomationRunSummary' }, { name: 'AutomationRunState', mutable: true }, - { name: 'CanvasExtensionSource', omitDiscriminants: true }, - { name: 'CanvasPackageSource', omitDiscriminants: true }, + { name: 'CanvasExtensionSource' }, + { name: 'CanvasPackageSource' }, { name: 'CanvasIdentityKey' }, { name: 'CanvasIdentity' }, - { name: 'CanvasTrustedState', omitDiscriminants: true }, - { name: 'CanvasPendingTrustState', omitDiscriminants: true }, - { name: 'CanvasBlockedTrustState', omitDiscriminants: true }, + { name: 'CanvasTrustedState' }, + { name: 'CanvasPendingTrustState' }, + { name: 'CanvasBlockedTrustState' }, { name: 'CanvasActionDeclaration' }, - { name: 'CanvasUnsupportedAvailabilityState', omitDiscriminants: true }, - { name: 'CanvasNotLoadedAvailabilityState', omitDiscriminants: true }, - { name: 'CanvasLoadingAvailabilityState', omitDiscriminants: true }, - { name: 'CanvasEmptyAvailabilityState', omitDiscriminants: true }, - { name: 'CanvasReadyAvailabilityState', omitDiscriminants: true }, - { name: 'CanvasFailedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasUnsupportedAvailabilityState' }, + { name: 'CanvasNotLoadedAvailabilityState' }, + { name: 'CanvasLoadingAvailabilityState' }, + { name: 'CanvasEmptyAvailabilityState' }, + { name: 'CanvasReadyAvailabilityState' }, + { name: 'CanvasFailedAvailabilityState' }, { name: 'CanvasEntry', mutable: true }, { name: 'CanvasState', mutable: true }, { name: 'CanvasTypeDeclaration' }, @@ -1336,6 +1336,9 @@ public sealed class SnapshotState /// Automation run state variant, when populated. public AutomationRunState? AutomationRun { get; set; } + + /// Canvas state variant, when populated. + public CanvasState? Canvas { get; set; } } /// System.Text.Json converter for the SnapshotState shape-probed union. @@ -1346,7 +1349,13 @@ internal sealed class SnapshotStateConverter : JsonConverter using var doc = JsonDocument.ParseValue(ref reader); var root = doc.RootElement; var result = new SnapshotState(); - if (root.TryGetProperty("automation", out _) && + if (root.TryGetProperty("identity", out _) && + root.TryGetProperty("availability", out _) && + root.TryGetProperty("revision", out _)) + { + result.Canvas = root.Deserialize(AhpJsonTypeInfo.Get(options)); + } + else if (root.TryGetProperty("automation", out _) && root.TryGetProperty("origin", out _) && root.TryGetProperty("sessions", out _)) { @@ -1392,6 +1401,7 @@ internal sealed class SnapshotStateConverter : JsonConverter public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) { + if (value.Canvas is not null) { JsonSerializer.Serialize(writer, value.Canvas, AhpJsonTypeInfo.Get(options)); return; } if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 4fcf813d..362238f6 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -1339,11 +1339,14 @@ type SnapshotState struct { \tAnnotations *AnnotationsState \`json:"-"\` \tAutomations *AutomationState \`json:"-"\` \tAutomationRun *AutomationRunState \`json:"-"\` +\tCanvas *CanvasState \`json:"-"\` } // MarshalJSON encodes whichever variant is currently populated. func (s SnapshotState) MarshalJSON() ([]byte, error) { \tswitch { +\tcase s.Canvas != nil: +\t\treturn json.Marshal(s.Canvas) \tcase s.AutomationRun != nil: \t\treturn json.Marshal(s.AutomationRun) \tcase s.Automations != nil: @@ -1376,6 +1379,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { \t\treturn err \t} \tswitch { +\tcase containsAll(probe, "identity", "availability", "revision"): +\t\tvar v CanvasState +\t\tif err := json.Unmarshal(data, &v); err != nil { +\t\t\treturn err +\t\t} +\t\ts.Canvas = &v \tcase containsAll(probe, "automation", "origin", "sessions"): \t\tvar v AutomationRunState \t\tif err := json.Unmarshal(data, &v); err != nil { @@ -1498,6 +1507,21 @@ function generateStateFile(project: Project): string { }), ); lines.push(''); + if (entry.name === 'SessionState') { + lines.push(`// MarshalJSON preserves an explicitly empty canvas catalogue. +func (s SessionState) MarshalJSON() ([]byte, error) { +\ttype wire SessionState +\tvar canvases *[]CanvasEntry +\tif s.Canvases != nil { +\t\tcanvases = &s.Canvases +\t} +\treturn json.Marshal(struct { +\t\twire +\t\tCanvases *[]CanvasEntry \`json:"canvases,omitempty"\` +\t}{wire(s), canvases}) +}`); + lines.push(''); + } } catch (e) { lines.push(`// TODO: could not generate ${entry.name}: ${e}`); lines.push(''); diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 0158663c..f5c7eae6 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -845,6 +845,7 @@ sealed interface SnapshotState { @JvmInline value class Annotations(val value: AnnotationsState) : SnapshotState @JvmInline value class Automations(val value: AutomationState) : SnapshotState @JvmInline value class AutomationRun(val value: AutomationRunState) : SnapshotState + @JvmInline value class Canvas(val value: CanvasState) : SnapshotState } internal object SnapshotStateSerializer : KSerializer { @@ -867,6 +868,8 @@ internal object SnapshotStateSerializer : KSerializer { // key); TerminalState has required \`content\`; RootState is the // catch-all. return when { + obj.containsKey("identity") && obj.containsKey("availability") && obj.containsKey("revision") -> + SnapshotState.Canvas(input.json.decodeFromJsonElement(CanvasState.serializer(), element)) obj.containsKey("automation") && obj.containsKey("origin") && obj.containsKey("sessions") -> SnapshotState.AutomationRun(input.json.decodeFromJsonElement(AutomationRunState.serializer(), element)) obj.containsKey("entries") -> @@ -898,6 +901,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Annotations -> output.json.encodeToJsonElement(AnnotationsState.serializer(), value.value) is SnapshotState.Automations -> output.json.encodeToJsonElement(AutomationState.serializer(), value.value) is SnapshotState.AutomationRun -> output.json.encodeToJsonElement(AutomationRunState.serializer(), value.value) + is SnapshotState.Canvas -> output.json.encodeToJsonElement(CanvasState.serializer(), value.value) } output.encodeJsonElement(element) } diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 02549e47..600f4c46 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -1344,6 +1344,7 @@ function generateSnapshotState(): string { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum SnapshotState { + Canvas(Box), Session(Box), Chat(Box), Terminal(Box), @@ -1657,7 +1658,7 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, CanvasAvailabilityState, CanvasEntry, CanvasTrustState, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum @@ -1835,7 +1836,7 @@ function generateCommandsFile(project: Project): string { lines.push('#[allow(unused_imports)]'); lines.push('use crate::actions::{ActionEnvelope, StateAction};'); lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, AutomationTrigger, AutomationTriggerDefinition, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); + lines.push('use crate::state::{AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, AutomationTrigger, AutomationTriggerDefinition, CanvasAvailabilityStatus, CanvasEntry, CanvasIdentityKey, CanvasSourcePresentation, CanvasTypeDeclaration, ContentRef, Icon, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index f29c35d8..7cd30f06 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -510,7 +510,7 @@ function generateDiscriminatedUnion(project: Project, config: UnionConfig): stri lines.push(`public enum ${config.name}: Codable, Sendable {`); for (const v of config.variants) { - lines.push(` case ${v.caseName}(${v.structName})`); + lines.push(` case ${swiftIdentifier(v.caseName)}(${v.structName})`); } if (allowUnknown) { lines.push(' /// Unknown or future discriminant; the raw payload is preserved'); @@ -538,7 +538,7 @@ function generateDiscriminatedUnion(project: Project, config: UnionConfig): stri lines.push(' switch discriminant {'); for (const v of config.variants) { lines.push(` case ${JSON.stringify(v.discriminantValue)}:`); - lines.push(` self = .${v.caseName}(try ${v.structName}(from: decoder))`); + lines.push(` self = .${swiftIdentifier(v.caseName)}(try ${v.structName}(from: decoder))`); } lines.push(' default:'); if (allowUnknown) { @@ -555,11 +555,11 @@ function generateDiscriminatedUnion(project: Project, config: UnionConfig): stri lines.push(' switch self {'); for (const v of config.variants) { if (config.injectDiscriminantOnEncode) { - lines.push(` case .${v.caseName}(var value):`); - lines.push(` value.${config.discriminantField} = .${v.caseName}`); + lines.push(` case .${swiftIdentifier(v.caseName)}(var value):`); + lines.push(` value.${swiftIdentifier(config.discriminantField)} = .${swiftIdentifier(v.caseName)}`); lines.push(' try value.encode(to: encoder)'); } else { - lines.push(` case .${v.caseName}(let value): try value.encode(to: encoder)`); + lines.push(` case .${swiftIdentifier(v.caseName)}(let value): try value.encode(to: encoder)`); } } if (allowUnknown) { @@ -1111,13 +1111,16 @@ public enum SnapshotState: Codable, Sendable { case annotations(AnnotationsState) case automations(AutomationState) case automationRun(AutomationRunState) + case canvas(CanvasState) public init(from decoder: Decoder) throws { // Try the most distinctive shapes first. SessionState has required // \`lifecycle\` / \`activeClients\` / \`chats\`; ChatState has required // \`turns\`; the remaining variants follow, with RootState as the // catch-all. - if let session = try? SessionState(from: decoder) { + if let canvas = try? CanvasState(from: decoder) { + self = .canvas(canvas) + } else if let session = try? SessionState(from: decoder) { self = .session(session) } else if let chat = try? ChatState(from: decoder) { self = .chat(chat) @@ -1149,6 +1152,7 @@ public enum SnapshotState: Codable, Sendable { case .annotations(let state): try state.encode(to: encoder) case .automations(let state): try state.encode(to: encoder) case .automationRun(let state): try state.encode(to: encoder) + case .canvas(let state): try state.encode(to: encoder) } } }`; diff --git a/types/common/commands.ts b/types/common/commands.ts index f93bbdd4..06a0395a 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -232,7 +232,7 @@ export interface ClientCapabilities { * {@link CanvasTrustStatus}, which is a separate, host-owned decision. * * This declares only the CLIENT's rendering capability. Protocol version - * support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + * support alone is not evidence that the SERVER * actually has a working canvas runtime — see * {@link InitializeResult.canvases}, the server-side counterpart, which a * client MUST also check before treating canvases as usable. @@ -322,7 +322,7 @@ export interface InitializeResult { * {@link ClientCapabilities.canvases} declared. * * **Protocol version support alone is not a runtime capability**: a host - * speaking protocol `>= 0.10.0` without this field present MUST NOT be + * speaking a supported protocol version without this field present MUST NOT be * assumed to have a usable canvas runtime. This field — not the * negotiated `protocolVersion` — is the authoritative signal, and is * independent of any individual canvas's live availability diff --git a/types/test-cases/round-trips/050-canvas-snapshot.json b/types/test-cases/round-trips/050-canvas-snapshot.json new file mode 100644 index 00000000..11cffbb1 --- /dev/null +++ b/types/test-cases/round-trips/050-canvas-snapshot.json @@ -0,0 +1,42 @@ +{ + "name": "canvas-snapshot", + "group": "A", + "description": "A canvas snapshot preserves its source, opaque incarnation, trust, availability and revision.", + "type": "Snapshot", + "input": { + "resource": "ahp-canvas:/c1", + "state": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "generation-one" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 4 + }, + "fromSeq": 8 + }, + "acceptableOutputs": [{ + "resource": "ahp-canvas:/c1", + "state": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "generation-one" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 4 + }, + "fromSeq": 8 + }] +} diff --git a/types/version/registry.test.ts b/types/version/registry.test.ts index 29862ed4..9cbc5912 100644 --- a/types/version/registry.test.ts +++ b/types/version/registry.test.ts @@ -76,3 +76,15 @@ test('public package entry re-exports both protocol-version constants', async () assert.equal(pkg.PROTOCOL_VERSION, PROTOCOL_VERSION); assert.deepEqual([...pkg.SUPPORTED_PROTOCOL_VERSIONS], [...SUPPORTED_PROTOCOL_VERSIONS]); }); + +test('capability-gated canvas actions use the current protocol without a release bump', () => { + const actions = [ + ActionType.SessionCanvasSet, + ActionType.SessionCanvasRemoved, + ActionType.CanvasAvailabilityChanged, + ActionType.CanvasTrustChanged, + ActionType.CanvasIncarnationChanged, + ActionType.CanvasTitleChanged, + ]; + assert.deepEqual(actions.map(type => ACTION_INTRODUCED_IN[type]), actions.map(() => '0.9.0')); +}); diff --git a/types/version/registry.ts b/types/version/registry.ts index b25ccf28..a1a1704e 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.10.0'; +export const PROTOCOL_VERSION = '0.9.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,7 +34,6 @@ export const PROTOCOL_VERSION = '0.10.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -177,12 +176,12 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.AutomationRunSessionRemoved]: '0.8.0', [ActionType.AutomationRunPrimarySessionChanged]: '0.8.0', [ActionType.AutomationRunCancelRequested]: '0.8.0', - [ActionType.SessionCanvasSet]: '0.10.0', - [ActionType.SessionCanvasRemoved]: '0.10.0', - [ActionType.CanvasAvailabilityChanged]: '0.10.0', - [ActionType.CanvasTrustChanged]: '0.10.0', - [ActionType.CanvasIncarnationChanged]: '0.10.0', - [ActionType.CanvasTitleChanged]: '0.10.0', + [ActionType.SessionCanvasSet]: '0.9.0', + [ActionType.SessionCanvasRemoved]: '0.9.0', + [ActionType.CanvasAvailabilityChanged]: '0.9.0', + [ActionType.CanvasTrustChanged]: '0.9.0', + [ActionType.CanvasIncarnationChanged]: '0.9.0', + [ActionType.CanvasTitleChanged]: '0.9.0', }; /**