From b0d3c5295f071ab1d0ee9b57bda8d77641b793bd Mon Sep 17 00:00:00 2001 From: Colby Williams Date: Wed, 1 Jul 2026 21:49:21 -0500 Subject: [PATCH 1/4] Add Canvas channel (spec + all clients) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Canvas surface proposed in #297. A Canvas is a host-driven, interactive UI surface (editor, browser, terminal, document view, …) that a session can open and drive over AHP. This adds the full protocol surface plus matching support in all five client mirrors. Protocol surface (types/): - ClientCapabilities.canvas opt-in capability. - Session-state discovery surface: SessionState.canvases (declared canvas types) and SessionState.openCanvases (live instances), plus SessionActiveClient.canvasProviders published atomically like tools. New supporting types CanvasAvailability, CanvasProviderKind, CanvasProviderSource (named discriminated union), SessionCanvasDeclaration, ClientCanvasDeclaration, OpenCanvasRef, SessionCanvasAction. - Per-instance channel ahp-canvas:/ carrying CanvasState and the canvas/updated, canvas/closeRequested, canvas/message actions, with canvasReducer applying a sparse merge for canvas/updated. - Server -> client canvas provider request family (canvasOpen / canvasInvokeAction / canvasClose) mirrored in CommandMap for symmetry with the resource* family, and client -> server canvasReadResource for the in-band content path. - New CanvasProviderError (-32012) with CanvasProviderErrorData. - Protocol version 0.5.1 -> 0.6.0; canvas actions registered at 0.6.0. Generators/tooling: canvas sources wired into find-protocol-sources, generate-action-origin, and the Go/Rust/Swift/Kotlin generators; regenerated client types, JSON schemas, action-origin table, and reference docs. Clients: hand-ported canvasReducer and the session canvas arms to Rust, Go, Swift, Kotlin, and TypeScript. The exhaustive Rust/Swift state mirrors also route the ahp-canvas: channel; the prefix-routing Go/TS mirrors need no change. Fixtures: 8 shared reducer fixtures (232-239) exercising the session registry replacement, open-canvas catalogue replacement, canvas/updated full and partial merges, and the closeRequested / message / unknown-action no-ops. Validated across the TypeScript, Rust, Go, Swift, and Kotlin fixture runners. Docs: new specification/canvas-channel.md and guide/canvases.md, plus subscriptions.md, session-channel.md, and sidebar updates. CHANGELOGs updated for the spec and all five clients. Refs #297 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 18 + clients/go/CHANGELOG.md | 12 + clients/go/ahp/reducers.go | 38 ++ clients/go/ahp/reducers_fixture_test.go | 2 + clients/go/ahptypes/actions.generated.go | 109 ++++ clients/go/ahptypes/commands.generated.go | 130 +++++ clients/go/ahptypes/errors.generated.go | 10 + clients/go/ahptypes/state.generated.go | 263 ++++++++- clients/go/ahptypes/version.generated.go | 3 +- clients/go/release-metadata.json | 1 + clients/kotlin/CHANGELOG.md | 10 + clients/kotlin/release-metadata.json | 1 + .../microsoft/agenthostprotocol/Reducers.kt | 25 + .../generated/Actions.generated.kt | 80 ++- .../generated/Commands.generated.kt | 150 ++++- .../generated/Errors.generated.kt | 16 + .../generated/State.generated.kt | 311 +++++++++- .../generated/Version.generated.kt | 3 +- .../FixtureDrivenReducerTest.kt | 13 + clients/rust/CHANGELOG.md | 10 + clients/rust/crates/ahp-types/src/actions.rs | 108 +++- clients/rust/crates/ahp-types/src/commands.rs | 156 +++++ clients/rust/crates/ahp-types/src/errors.rs | 13 + clients/rust/crates/ahp-types/src/state.rs | 237 +++++++- clients/rust/crates/ahp-types/src/version.rs | 4 +- .../crates/ahp/src/multi_host_state_mirror.rs | 24 +- clients/rust/crates/ahp/src/reducers.rs | 58 +- .../ahp/tests/multi_host_state_mirror.rs | 2 + clients/rust/release-metadata.json | 1 + .../Generated/Actions.generated.swift | 103 ++++ .../Generated/Commands.generated.swift | 174 +++++- .../Generated/Errors.generated.swift | 19 + .../Generated/State.generated.swift | 301 +++++++++- .../Generated/Version.generated.swift | 3 +- .../Sources/AgentHostProtocol/Reducers.swift | 43 ++ .../AHPStateMirror.swift | 9 + .../MultiHostStateMirror.swift | 10 + .../FixtureDrivenReducerTests.swift | 4 + clients/swift/CHANGELOG.md | 10 + clients/swift/release-metadata.json | 1 + clients/typescript/CHANGELOG.md | 12 + clients/typescript/release-metadata.json | 1 + docs/.vitepress/config.mts | 2 + docs/guide/canvases.md | 51 ++ docs/specification/canvas-channel.md | 208 +++++++ docs/specification/session-channel.md | 9 + docs/specification/subscriptions.md | 4 +- schema/actions.schema.json | 381 ++++++++++++ schema/commands.schema.json | 544 ++++++++++++++++++ schema/errors.schema.json | 472 ++++++++++++++- schema/notifications.schema.json | 258 +++++++++ schema/state.schema.json | 270 +++++++++ scripts/find-protocol-sources.ts | 1 + scripts/generate-action-origin.ts | 46 +- scripts/generate-go.ts | 60 +- scripts/generate-kotlin.ts | 42 +- scripts/generate-rust.ts | 55 +- scripts/generate-swift.ts | 40 +- types/action-origin.generated.ts | 32 ++ types/actions.ts | 1 + types/channels-canvas/actions.ts | 73 +++ types/channels-canvas/commands.ts | 221 +++++++ types/channels-canvas/reducer.ts | 45 ++ types/channels-canvas/state.ts | 65 +++ types/channels-session/actions.ts | 37 ++ types/channels-session/reducer.ts | 6 + types/channels-session/state.ts | 198 ++++++- types/commands.ts | 1 + types/common/actions.ts | 20 +- types/common/commands.ts | 12 + types/common/errors.ts | 28 + types/common/messages.ts | 23 + types/common/state.ts | 3 +- types/index.ts | 28 + types/messages.test.ts | 1 + types/reducers.test.ts | 6 +- types/reducers.ts | 1 + types/state.ts | 1 + ...session-canvaseschanged-sets-registry.json | 65 +++ ...on-opencanvaseschanged-sets-catalogue.json | 61 ++ .../234-canvas-updated-merges-all-fields.json | 37 ++ ...nvas-updated-partial-preserves-absent.json | 31 + ...-canvas-updated-partial-complementary.json | 31 + .../237-canvas-closerequested-no-op.json | 25 + .../reducers/238-canvas-message-no-op.json | 26 + ...9-canvas-unknown-action-type-is-no-op.json | 23 + types/version/message-checks.ts | 11 +- types/version/registry.ts | 8 +- 88 files changed, 6008 insertions(+), 53 deletions(-) create mode 100644 docs/guide/canvases.md create mode 100644 docs/specification/canvas-channel.md 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/232-session-canvaseschanged-sets-registry.json create mode 100644 types/test-cases/reducers/233-session-opencanvaseschanged-sets-catalogue.json create mode 100644 types/test-cases/reducers/234-canvas-updated-merges-all-fields.json create mode 100644 types/test-cases/reducers/235-canvas-updated-partial-preserves-absent.json create mode 100644 types/test-cases/reducers/236-canvas-updated-partial-complementary.json create mode 100644 types/test-cases/reducers/237-canvas-closerequested-no-op.json create mode 100644 types/test-cases/reducers/238-canvas-message-no-op.json create mode 100644 types/test-cases/reducers/239-canvas-unknown-action-type-is-no-op.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee62817..8ec33872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). +## [0.6.0] — Unreleased + +Spec version: `0.6.0` + ### Added - Optional `intention` field on `chat/toolCallStart` and every `ToolCallState` @@ -39,6 +43,20 @@ changes accumulate. Track in-flight protocol changes via PRs touching extends `PaginatedResult`, letting clients fetch a large session catalogue incrementally. Fully additive — omitting the fields preserves today's behaviour. +- Canvas channel — an opt-in surface for the agent to open rich, interactive UI + surfaces (document/spreadsheet editors, diff views, live previews) alongside a + session, following the "one channel per resource" model of terminals and + changesets. Adds the `ClientCapabilities.canvas` capability; the + `SessionState.canvases` registry and `SessionState.openCanvases` catalogue + (`session/canvasesChanged` / `session/openCanvasesChanged`) plus + `SessionActiveClient.canvasProviders` and the `SessionCanvasDeclaration` / + `ClientCanvasDeclaration` / `OpenCanvasRef` / `CanvasProviderSource` discovery + types; the per-instance `ahp-canvas:/` channel and its `CanvasState`; the + `canvas/updated`, `canvas/closeRequested`, and `canvas/message` actions; the + `canvasOpen` / `canvasInvokeAction` / `canvasClose` provider request family and + the client → server `canvasReadResource` content-fetch request; and the + `CanvasProviderError` (`-32012`) error. See + [`docs/specification/canvas-channel.md`](docs/specification/canvas-channel.md). ### Removed diff --git a/clients/go/CHANGELOG.md b/clients/go/CHANGELOG.md index fd43e073..29eefcaf 100644 --- a/clients/go/CHANGELOG.md +++ b/clients/go/CHANGELOG.md @@ -40,6 +40,18 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file. lifecycle state. - Optional `Model` and `Tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +- Canvas channel support: the per-instance `CanvasState` plus the + `CanvasUpdatedAction` (wire `canvas/updated`), `CanvasCloseRequestedAction` + (`canvas/closeRequested`), and `CanvasMessageAction` (`canvas/message`) + actions, the `SessionCanvasesChangedAction` (`session/canvasesChanged`) and + `SessionOpenCanvasesChangedAction` (`session/openCanvasesChanged`) session + actions, and the canvas discovery types (`SessionCanvasDeclaration`, + `ClientCanvasDeclaration`, `OpenCanvasRef`, `CanvasProviderSource`) on + `SessionState.Canvases` / `SessionState.OpenCanvases`. Adds the + `ClientCapabilities.Canvas` capability, the `canvasOpen` / `canvasInvokeAction` + / `canvasClose` / `canvasReadResource` methods, and the `CanvasProviderError` + error. The session reducer replaces the canvas registry/catalogue and + `ApplyActionToCanvas` sparse-merges `canvas/updated`. ### Removed diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 45b4fc92..71666f49 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -738,6 +738,12 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct case *ahptypes.SessionServerToolsChangedAction: state.ServerTools = append([]ahptypes.ToolDefinition(nil), a.Tools...) return ReduceOutcomeApplied + case *ahptypes.SessionCanvasesChangedAction: + state.Canvases = append([]ahptypes.SessionCanvasDeclaration(nil), a.Canvases...) + return ReduceOutcomeApplied + case *ahptypes.SessionOpenCanvasesChangedAction: + state.OpenCanvases = append([]ahptypes.OpenCanvasRef(nil), a.OpenCanvases...) + return ReduceOutcomeApplied case *ahptypes.SessionActiveClientSetAction: for i := range state.ActiveClients { if state.ActiveClients[i].ClientId == a.ActiveClient.ClientId { @@ -1476,3 +1482,35 @@ func ApplyActionToResourceWatch(state *ahptypes.ResourceWatchState, action ahpty } return ReduceOutcomeOutOfScope } + +// ApplyActionToCanvas applies action to the [ahptypes.CanvasState] in +// place. `canvas/updated` is a sparse merge — a presented field +// (title, status, url, availability) overwrites the corresponding +// state field and an absent field preserves the current value. +// `canvas/closeRequested` and `canvas/message` are side-effect-only +// client→host signals the host acts on out of band, so they leave the +// state unchanged. Returns [ReduceOutcomeOutOfScope] for actions that +// target a different state tree. +func ApplyActionToCanvas(state *ahptypes.CanvasState, action ahptypes.StateAction) ReduceOutcome { + switch a := action.Value.(type) { + case *ahptypes.CanvasUpdatedAction: + if a.Title != nil { + state.Title = a.Title + } + if a.Status != nil { + state.Status = a.Status + } + if a.Url != nil { + state.Url = a.Url + } + if a.Availability != nil { + state.Availability = *a.Availability + } + return ReduceOutcomeApplied + case *ahptypes.CanvasCloseRequestedAction: + return ReduceOutcomeNoOp + case *ahptypes.CanvasMessageAction: + return ReduceOutcomeNoOp + } + return ReduceOutcomeOutOfScope +} diff --git a/clients/go/ahp/reducers_fixture_test.go b/clients/go/ahp/reducers_fixture_test.go index 9333353c..edaab2e2 100644 --- a/clients/go/ahp/reducers_fixture_test.go +++ b/clients/go/ahp/reducers_fixture_test.go @@ -160,6 +160,8 @@ func TestFixtureDrivenReducerParity(t *testing.T) { runFixture[ahptypes.AnnotationsState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAnnotations) case "resourceWatch": runFixture[ahptypes.ResourceWatchState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToResourceWatch) + 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/actions.generated.go b/clients/go/ahptypes/actions.generated.go index fd7b7d08..1b56fbb6 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -94,6 +94,11 @@ const ( ActionTypeTerminalCommandExecuted ActionType = "terminal/commandExecuted" ActionTypeTerminalCommandFinished ActionType = "terminal/commandFinished" ActionTypeResourceWatchChanged ActionType = "resourceWatch/changed" + ActionTypeSessionCanvasesChanged ActionType = "session/canvasesChanged" + ActionTypeSessionOpenCanvasesChanged ActionType = "session/openCanvasesChanged" + ActionTypeCanvasUpdated ActionType = "canvas/updated" + ActionTypeCanvasCloseRequested ActionType = "canvas/closeRequested" + ActionTypeCanvasMessage ActionType = "canvas/message" ) // ─── Action Envelope ───────────────────────────────────────────────── @@ -728,6 +733,29 @@ type SessionServerToolsChangedAction struct { Tools []ToolDefinition `json:"tools"` } +// The aggregated canvas registry for this session changed. +// +// Full-replacement semantics: the `canvases` array replaces +// {@link SessionState.canvases} entirely, mirroring +// `session/serverToolsChanged`. The host republishes the union of every +// connected provider (server-side and client-declared) whenever it changes. +type SessionCanvasesChangedAction struct { + Type ActionType `json:"type"` + // Updated canvas registry (full replacement). + Canvases []SessionCanvasDeclaration `json:"canvases"` +} + +// The catalogue of open canvas instances for this session changed. +// +// Full-replacement semantics: the `openCanvases` array replaces +// {@link SessionState.openCanvases} entirely. The host republishes the +// catalogue as instances open and close. +type SessionOpenCanvasesChangedAction struct { + Type ActionType `json:"type"` + // Updated open-instance catalogue (full replacement). + OpenCanvases []OpenCanvasRef `json:"openCanvases"` +} + // An active client for this session was added or updated. // // Upsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}: @@ -1233,6 +1261,52 @@ type ResourceWatchChangedAction struct { Changes json.RawMessage `json:"changes"` } +// The canvas instance's presentation state changed. +// +// Sparse-merge semantics: each present field overwrites the corresponding +// {@link CanvasState} field, and an absent field preserves the current value. +// There is no clear-to-absent via this action — that three-state distinction +// cannot survive JSON transport uniformly across languages, so a provider that +// needs to reset a field re-publishes it, and a full reset arrives as a fresh +// {@link CanvasState} snapshot on (re)subscribe. +type CanvasUpdatedAction struct { + Type ActionType `json:"type"` + // New title. Absent preserves the current title. + Title *string `json:"title,omitempty"` + // New provider-defined status. Absent preserves the current status. + Status *string `json:"status,omitempty"` + // New content address. Absent preserves the current url. + Url *string `json:"url,omitempty"` + // New availability. Absent preserves the current availability. + Availability *CanvasAvailability `json:"availability,omitempty"` +} + +// The user asked to close this canvas (e.g. hit the ✕ on the surface). +// +// A pure client→host signal with a no-op reducer, mirroring how +// `terminal/input` is a side-effect-only client action. The host runs the +// close flow in response — resolving `canvasClose` against the provider and +// dropping the instance from {@link SessionState.openCanvases} — rather than +// the reducer mutating channel state. +type CanvasCloseRequestedAction struct { + Type ActionType `json:"type"` +} + +// An opaque message relayed between the rendered canvas View and the +// instance's provider — the relay-carried analogue of a `postMessage` bridge. +// +// Bidirectional: a client dispatches it to carry a View→provider message, and +// the host emits it to carry a provider→View message (routed to the provider +// resolved for the instance, or handled host-internally for a server-side +// provider). Like `terminal/input` and {@link CanvasCloseRequestedAction} it +// is a pure signal with a no-op reducer, so it never bloats channel state. See +// {@link /specification/canvas-channel | Canvas Channel}. +type CanvasMessageAction struct { + Type ActionType `json:"type"` + // Opaque, provider-defined message payload. + Payload json.RawMessage `json:"payload"` +} + // ─── StateAction Union ─────────────────────────────────────────────── // StateAction is the discriminated union of every state action. @@ -1283,6 +1357,8 @@ func (*SessionIsArchivedChangedAction) isStateAction() {} func (*SessionActivityChangedAction) isStateAction() {} func (*SessionChangesetsChangedAction) isStateAction() {} func (*SessionServerToolsChangedAction) isStateAction() {} +func (*SessionCanvasesChangedAction) isStateAction() {} +func (*SessionOpenCanvasesChangedAction) isStateAction() {} func (*SessionActiveClientSetAction) isStateAction() {} func (*SessionActiveClientRemovedAction) isStateAction() {} func (*SessionInputNeededSetAction) isStateAction() {} @@ -1319,6 +1395,9 @@ func (*TerminalCommandDetectionAvailableAction) isStateAction() {} func (*TerminalCommandExecutedAction) isStateAction() {} func (*TerminalCommandFinishedAction) isStateAction() {} func (*ResourceWatchChangedAction) isStateAction() {} +func (*CanvasUpdatedAction) isStateAction() {} +func (*CanvasCloseRequestedAction) isStateAction() {} +func (*CanvasMessageAction) 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 { @@ -1568,6 +1647,18 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "session/canvasesChanged": + var value SessionCanvasesChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "session/openCanvasesChanged": + var value SessionOpenCanvasesChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "session/activeClientSet": var value SessionActiveClientSetAction if err := json.Unmarshal(data, &value); err != nil { @@ -1784,6 +1875,24 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "canvas/updated": + var value CanvasUpdatedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/closeRequested": + var value CanvasCloseRequestedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/message": + var value CanvasMessageAction + 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 c7b03f49..c9169c74 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -157,6 +157,16 @@ 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 canvases and host client-declared canvas providers — it + // can render an opaque canvas URL in an isolated surface, and it can answer + // `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases + // it declares via {@link SessionActiveClient.canvasProviders}. + // + // Hosts SHOULD only populate {@link SessionState.canvases} / + // {@link SessionState.openCanvases} and only route canvas requests to a + // client that declared this capability. Clients that omit it see no canvas + // surface. See {@link /specification/canvas-channel | Canvas Channel}. + Canvas map[string]json.RawMessage `json:"canvas,omitempty"` } // Re-establishes a dropped connection. The server replays missed actions or @@ -946,6 +956,126 @@ type ChangesetOperationFollowUp struct { External *bool `json:"external,omitempty"` } +// Opens a canvas instance against its provider. +// +// Sent by the host to the client that declared the target canvas via +// {@link SessionActiveClient.canvasProviders} (a client that also declared +// {@link ClientCapabilities.canvas}). For a server-side provider the host +// resolves the open host-internally and emits no request. The provider returns +// the initial render target and presentation fields, which the host folds into +// the new instance's {@link CanvasState}. +// +// Mirrors the `resource*` precedent: registered in `ServerCommandMap` and +// mirrored in `CommandMap` for symmetry. A client normally never initiates it — +// the host is not a canvas provider — and a receiver SHOULD reject a request +// whose target is not one of its declared providers. +type CanvasOpenParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Provider-local canvas id to open. + CanvasId string `json:"canvasId"` + // Owning provider id. + ExtensionId string `json:"extensionId"` + // Caller-minted handle for the new instance. + InstanceId string `json:"instanceId"` + // Open input, validated by the provider against its declared schema. + Input map[string]json.RawMessage `json:"input,omitempty"` +} + +// Result of the `canvasOpen` command. +type CanvasOpenResult struct { + // Initial content address for the instance (see {@link CanvasState.url}). + Url *string `json:"url,omitempty"` + // Initial title. + Title *string `json:"title,omitempty"` + // Initial provider-defined status. + Status *string `json:"status,omitempty"` +} + +// Invokes one of a canvas's declared actions against its provider. +// +// Sent by the host to the providing client (or resolved host-internally for a +// server-side provider) when the agent invokes a +// {@link SessionCanvasAction | declared action} on an open instance. The +// provider returns an opaque, provider-defined value. Registered symmetrically +// with the rest of the provider family (see {@link CanvasOpenParams}). +type CanvasInvokeActionParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Instance handle the action targets. + InstanceId string `json:"instanceId"` + // Provider-local canvas id of the instance. + CanvasId string `json:"canvasId"` + // Owning provider id. + ExtensionId string `json:"extensionId"` + // Declared action name to invoke. + ActionName string `json:"actionName"` + // Action input, validated by the provider against its declared schema. + Input map[string]json.RawMessage `json:"input,omitempty"` +} + +// Result of the `canvasInvokeAction` command. +type CanvasInvokeActionResult struct { + // Opaque, provider-defined return value. + Value *json.RawMessage `json:"value,omitempty"` +} + +// Closes a canvas instance against its provider. +// +// Sent by the host to the providing client (or resolved host-internally for a +// server-side provider) as part of the close flow — typically after a client +// dispatches `canvas/closeRequested`. The host then drops the instance from +// {@link SessionState.openCanvases}. Registered symmetrically with the rest of +// the provider family (see {@link CanvasOpenParams}). +type CanvasCloseParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Instance handle to close. + InstanceId string `json:"instanceId"` + // Provider-local canvas id of the instance. + CanvasId string `json:"canvasId"` + // Owning provider id. + ExtensionId string `json:"extensionId"` +} + +// Reads channel-served canvas content by `ahp-canvas-content:` URI. +// +// A client → host request, modeled on MCP's `resources/read`. When a canvas's +// {@link CanvasState.url} (or a sub-resource the rendered document references) +// is an `ahp-canvas-content://` address, the renderer cannot +// dial the host directly — for example in a relayed deployment behind a broker +// — so it resolves the bytes over the instance's existing `ahp-canvas:/` +// channel with this request instead of loading a network URL. The `` +// segment of the URI identifies which canvas channel to read from. See +// {@link /specification/canvas-channel | Canvas Channel}. +type CanvasReadResourceParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // An `ahp-canvas-content://` content URI to read. + Uri string `json:"uri"` +} + +// Result of the `canvasReadResource` command. +type CanvasReadResourceResult struct { + // The resolved content parts, wrapped for forward compatibility. + Contents []CanvasResourceContent `json:"contents"` +} + +// One resolved piece of channel-served canvas content. +// +// Carries exactly one of {@link text} (text payloads) or {@link blob} +// (base64-encoded binary payloads). +type CanvasResourceContent struct { + // The content URI this part resolves. + Uri string `json:"uri"` + // MIME type of the content, when known. + MimeType *string `json:"mimeType,omitempty"` + // UTF-8 text content, for text payloads. + Text *string `json:"text,omitempty"` + // Base64-encoded content, for binary payloads. + Blob *string `json:"blob,omitempty"` +} + // ─── ReconnectResult Union ──────────────────────────────────────────── // ReconnectResult is the result of the `reconnect` command. diff --git a/clients/go/ahptypes/errors.generated.go b/clients/go/ahptypes/errors.generated.go index 3b3e8f82..008c52d6 100644 --- a/clients/go/ahptypes/errors.generated.go +++ b/clients/go/ahptypes/errors.generated.go @@ -36,6 +36,8 @@ const ( ErrorCodeNotFound int32 = -32008 ErrorCodePermissionDenied int32 = -32009 ErrorCodeAlreadyExists int32 = -32010 + ErrorCodeConflict int32 = -32011 + ErrorCodeCanvasProviderError int32 = -32012 ) // AhpErrorCode is the type alias used by AHP application error codes. @@ -63,3 +65,11 @@ type PermissionDeniedErrorData struct { type UnsupportedProtocolVersionErrorData struct { SupportedVersions []string `json:"supportedVersions"` } + +// CanvasProviderErrorData is the detail payload of a +// CanvasProviderError (-32012) error. The Code is a provider-defined +// string (opaque to AHP) identifying the failure. +type CanvasProviderErrorData struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 700beae5..898d0753 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -416,6 +416,28 @@ const ( ResourceChangeTypeDeleted ResourceChangeType = "deleted" ) +// Availability of a canvas provider or open instance. +type CanvasAvailability string + +const ( + // The provider is connected and can service requests for this canvas. + CanvasAvailabilityReady CanvasAvailability = "ready" + // The provider is temporarily unavailable (for example, a client provider + // that disconnected). The entry is retained so it can be restored when the + // provider reconnects; in-flight requests fail until then. + CanvasAvailabilityStale CanvasAvailability = "stale" +) + +// Discriminant for {@link CanvasProviderSource}. +type CanvasProviderKind string + +const ( + // The canvas is provided by the host itself. + CanvasProviderKindServer CanvasProviderKind = "server" + // The canvas is provided by a connected client. + CanvasProviderKindClient CanvasProviderKind = "client" +) + // ─── Structs ────────────────────────────────────────────────────────── // An optionally-sized icon that can be displayed in a user interface. @@ -767,6 +789,25 @@ type SessionState struct { // chats raise requests and removes them with `session/inputNeededRemoved` // once the underlying request resolves. InputNeeded []SessionInputRequest `json:"inputNeeded,omitempty"` + // Aggregated canvas registry currently exposed to the agent — the union of + // every connected provider (server-side and client-declared). Each entry + // describes a canvas the agent can open; the host folds + // {@link SessionActiveClient.canvasProviders | client-declared providers} + // into this list alongside its own server-side providers. + // + // Full-replacement via `session/canvasesChanged`. Populated only when at + // least one connected client declared {@link ClientCapabilities.canvas}; + // absent for sessions with no canvas surface. See + // {@link /specification/canvas-channel | Canvas Channel}. + Canvases []SessionCanvasDeclaration `json:"canvases,omitempty"` + // Lightweight catalogue of currently-open canvas instances. Each entry + // carries the instance's `ahp-canvas:/` channel URI so a subscriber can + // subscribe to the full {@link CanvasState} and render it — analogous to how + // {@link RootState.terminals} catalogues live terminals whose full state + // lives on each terminal channel. + // + // Full-replacement via `session/openCanvasesChanged`. + OpenCanvases []OpenCanvasRef `json:"openCanvases,omitempty"` // Additional provider-specific metadata for this session. // // Clients MAY look for well-known keys here to provide enhanced UI. @@ -794,6 +835,17 @@ type SessionActiveClient struct { // plugins in memory and rely on the host to expand them into concrete // children inside {@link SessionState.customizations}. Customizations []ClientPluginCustomization `json:"customizations,omitempty"` + // Canvas declarations this client contributes as a provider. Published + // atomically with the rest of the active-client entry via + // `session/activeClientSet` — exactly like {@link tools} — so there is no + // separate canvas-provider change action. The host folds these into + // {@link SessionState.canvases} with + // {@link SessionCanvasDeclaration.source | `source`} set to + // `{ kind: 'client', clientId }` and routes + // `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back + // to this client. Only meaningful for a client that declared + // {@link ClientCapabilities.canvas}. + CanvasProviders []ClientCanvasDeclaration `json:"canvasProviders,omitempty"` } // A user-input elicitation surfaced at the session level, mirroring one entry @@ -2966,6 +3018,140 @@ type ResourceChange struct { Type ResourceChangeType `json:"type"` } +// One named action a canvas exposes to the agent, mirroring the shape of a +// {@link ToolDefinition} entry. Unique within its owning +// `(extensionId, canvasId)`. +type SessionCanvasAction struct { + // Action name, unique within the owning `(extensionId, canvasId)`. + Name string `json:"name"` + // Human-readable description of what the action does. + Description *string `json:"description,omitempty"` + // JSON Schema for the action's input. Opaque to AHP; mirrors the + // {@link ToolDefinition.inputSchema} shape. + InputSchema map[string]json.RawMessage `json:"inputSchema,omitempty"` +} + +// One entry in the aggregated {@link SessionState.canvases} registry — a canvas +// the agent can open, contributed by a server-side or client-declared provider. +type SessionCanvasDeclaration struct { + // Owning provider id. Stable across declarations and instances. + ExtensionId string `json:"extensionId"` + // Human-readable provider name. + ExtensionName *string `json:"extensionName,omitempty"` + // Provider-local canvas id. Unique within `extensionId`. + CanvasId string `json:"canvasId"` + // Human-readable canvas name. + DisplayName string `json:"displayName"` + // Human-readable description of the canvas. + Description string `json:"description"` + // JSON Schema for the canvas's open input. Opaque to AHP; mirrors the + // {@link ToolDefinition.inputSchema} shape. + InputSchema map[string]json.RawMessage `json:"inputSchema,omitempty"` + // Actions this canvas exposes to the agent. + Actions []SessionCanvasAction `json:"actions,omitempty"` + // Where the declaration came from — for routing and cleanup. + Source CanvasProviderSource `json:"source"` +} + +// The lighter declaration shape a client publishes on +// {@link SessionActiveClient.canvasProviders}. The host derives the +// `extensionId` and {@link SessionCanvasDeclaration.source | `source`} when +// folding it into {@link SessionState.canvases}. +type ClientCanvasDeclaration struct { + // Provider-local canvas id, unique within the publishing client. + CanvasId string `json:"canvasId"` + // Human-readable canvas name. + DisplayName string `json:"displayName"` + // Human-readable description of the canvas. + Description string `json:"description"` + // JSON Schema for the canvas's open input. Opaque to AHP. + InputSchema map[string]json.RawMessage `json:"inputSchema,omitempty"` + // Actions this canvas exposes to the agent. + Actions []SessionCanvasAction `json:"actions,omitempty"` +} + +// A lightweight catalogue entry for one open canvas instance, surfaced on +// {@link SessionState.openCanvases}. The authoritative, mutable per-instance +// state lives on the instance's own {@link CanvasState} channel; this entry +// exists so a subscriber can discover the channel URI and render it without +// subscribing to every instance. +type OpenCanvasRef struct { + // Server-assigned instance handle, unique within the session. + InstanceId string `json:"instanceId"` + // The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load + // the full {@link CanvasState}. + Channel URI `json:"channel"` + // Provider-local canvas id this instance was opened from. + CanvasId string `json:"canvasId"` + // Owning provider id. + ExtensionId string `json:"extensionId"` + // Human-readable provider name. + ExtensionName *string `json:"extensionName,omitempty"` + // Current instance title, mirrored from {@link CanvasState.title}. + Title *string `json:"title,omitempty"` + // Whether the instance's provider is currently available. + Availability CanvasAvailability `json:"availability"` +} + +// A canvas provided by the host. Carries no `clientId` — server-side provider +// requests are resolved host-internally rather than routed to a peer. +type CanvasServerProviderSource struct { + Kind CanvasProviderKind `json:"kind"` +} + +// A canvas provided by a connected client. The host routes +// `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas +// to the identified client. +type CanvasClientProviderSource struct { + Kind CanvasProviderKind `json:"kind"` + // `clientId` of the providing client (matches `initialize`). + ClientId string `json:"clientId"` +} + +// Full state for a single open canvas instance, delivered when a client +// subscribes to the instance's `ahp-canvas:/` channel. +// +// One channel exists per open instance — the same "one channel per resource" +// convention used by terminals, changesets, and resource watches. The +// lightweight catalogue entry that advertises this channel is +// {@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the +// authoritative, mutable per-instance view a renderer reads. +// +// Rendering is state-driven: a client renders the canvas by reading +// {@link url} and resolving it per the renderer's URL policy — directly for a +// reachable address, or over this channel via `canvasReadResource` for an +// `ahp-canvas-content:` address. It never receives a "render this" request. +type CanvasState struct { + // Server-assigned instance handle, unique within the session. + InstanceId string `json:"instanceId"` + // Provider-local canvas id this instance was opened from. + CanvasId string `json:"canvasId"` + // Owning provider id. + ExtensionId string `json:"extensionId"` + // Human-readable provider name. + ExtensionName *string `json:"extensionName,omitempty"` + // Human-readable canvas name. + DisplayName *string `json:"displayName,omitempty"` + // Input the agent supplied when opening the instance. Retained so the + // instance can be resumed or rebound after a reconnect. + Input map[string]json.RawMessage `json:"input,omitempty"` + // Current instance title. + Title *string `json:"title,omitempty"` + // Provider-defined status string (opaque to AHP). + Status *string `json:"status,omitempty"` + // Renderer-targeted address for the opaque canvas content — either a + // directly-loadable URL (`https:`, an in-process scheme, `http://localhost`) + // or a channel-served `ahp-canvas-content://` address the + // renderer resolves over this channel with `canvasReadResource`. The + // renderer dispatches on the scheme and enforces its URL policy. See + // {@link /specification/canvas-channel | Canvas Channel}. + Url *string `json:"url,omitempty"` + // Whether this instance's provider is currently available. + Availability CanvasAvailability `json:"availability"` + // Which provider owns the callbacks (`canvasOpen` / … ) for this instance. + Provider CanvasProviderSource `json:"provider"` +} + // ─── Discriminated Unions ───────────────────────────────────────────── // ResponsePart is a single part of a response stream (text, tool call, reasoning, content reference). @@ -4150,6 +4336,66 @@ func (u SessionInputRequest) MarshalJSON() ([]byte, error) { return json.Marshal(u.Value) } +// CanvasProviderSource identifies where a canvas declaration came from — used for request routing and cleanup when its provider disconnects. +type CanvasProviderSource struct { + Value isCanvasProviderSource +} + +// isCanvasProviderSource is the marker interface implemented by every +// concrete variant of CanvasProviderSource. +type isCanvasProviderSource interface{ isCanvasProviderSource() } + +func (*CanvasServerProviderSource) isCanvasProviderSource() {} +func (*CanvasClientProviderSource) isCanvasProviderSource() {} + +// CanvasProviderSourceUnknown carries an unrecognized CanvasProviderSource 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 CanvasProviderSourceUnknown struct { + Raw json.RawMessage +} + +func (*CanvasProviderSourceUnknown) isCanvasProviderSource() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *CanvasProviderSource) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "server": + var value CanvasServerProviderSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "client": + var value CanvasClientProviderSource + 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 = &CanvasProviderSourceUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u CanvasProviderSource) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*CanvasProviderSourceUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + // ChatOrigin describes how a chat came into existence. type ChatOrigin struct { Value isChatOrigin @@ -4232,10 +4478,10 @@ func (o ChatOrigin) MarshalJSON() ([]byte, error) { } // SnapshotState is the state payload of a snapshot — root, session, -// chat, terminal, changeset, resource-watch, or annotations state. The active -// variant is chosen by which pointer field is non-nil; UnmarshalJSON probes -// for required fields in the canonical order -// (session → chat → terminal → changeset → resourceWatch → annotations → root). +// chat, terminal, changeset, resource-watch, canvas, or annotations state. The +// active variant is chosen by which pointer field is non-nil; UnmarshalJSON +// probes for required fields in the canonical order +// (session → chat → terminal → changeset → resourceWatch → canvas → annotations → root). type SnapshotState struct { Root *RootState `json:"-"` Session *SessionState `json:"-"` @@ -4243,6 +4489,7 @@ type SnapshotState struct { Terminal *TerminalState `json:"-"` Changeset *ChangesetState `json:"-"` ResourceWatch *ResourceWatchState `json:"-"` + Canvas *CanvasState `json:"-"` Annotations *AnnotationsState `json:"-"` } @@ -4259,6 +4506,8 @@ func (s SnapshotState) MarshalJSON() ([]byte, error) { return json.Marshal(s.Changeset) case s.ResourceWatch != nil: return json.Marshal(s.ResourceWatch) + case s.Canvas != nil: + return json.Marshal(s.Canvas) case s.Annotations != nil: return json.Marshal(s.Annotations) case s.Root != nil: @@ -4307,6 +4556,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { return err } s.ResourceWatch = &v + case containsAll(probe, "canvasId", "provider"): + var v CanvasState + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Canvas = &v case containsAll(probe, "annotations"): var v AnnotationsState 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 8d270be4..320619c1 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.5.1" +const ProtocolVersion = "0.6.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ + "0.6.0", "0.5.1", "0.5.0", } diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index c8eed75a..37023344 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,6 +2,7 @@ "client": "go", "packageVersion": "0.5.0", "supportedProtocolVersions": [ + "0.6.0", "0.5.1", "0.5.0" ] diff --git a/clients/kotlin/CHANGELOG.md b/clients/kotlin/CHANGELOG.md index eb5c051e..9643502b 100644 --- a/clients/kotlin/CHANGELOG.md +++ b/clients/kotlin/CHANGELOG.md @@ -39,6 +39,16 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump lifecycle state. - Optional `model` and `tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +- Canvas channel support: the per-instance `CanvasState` plus the + `CanvasUpdatedAction`, `CanvasCloseRequestedAction`, and `CanvasMessageAction` + actions, the `SessionCanvasesChangedAction` / `SessionOpenCanvasesChangedAction` + session actions, and the canvas discovery types (`SessionCanvasDeclaration`, + `ClientCanvasDeclaration`, `OpenCanvasRef`, `CanvasProviderSource`) on + `SessionState.canvases` / `SessionState.openCanvases`. Adds the + `ClientCapabilities.canvas` capability, the `canvasOpen` / `canvasInvokeAction` + / `canvasClose` / `canvasReadResource` methods, and the `CanvasProviderError` + error. The session reducer replaces the canvas registry/catalogue and the + canvas reducer sparse-merges `canvas/updated`. ### Removed diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index d9e0f413..f56205b0 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,6 +2,7 @@ "client": "kotlin", "packageVersion": "0.5.0", "supportedProtocolVersions": [ + "0.6.0", "0.5.1", "0.5.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 85b5ccc1..e607bf6f 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -537,6 +537,10 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat is StateActionSessionServerToolsChanged -> state.copy(serverTools = action.value.tools) + is StateActionSessionCanvasesChanged -> state.copy(canvases = action.value.canvases) + + is StateActionSessionOpenCanvasesChanged -> state.copy(openCanvases = action.value.openCanvases) + is StateActionSessionActiveClientSet -> { val client = action.value.activeClient val idx = state.activeClients.indexOfFirst { it.clientId == client.clientId } @@ -1455,3 +1459,24 @@ public fun resourceWatchReducer(state: ResourceWatchState, action: StateAction): is StateActionResourceWatchChanged -> state else -> state } + +/** + * Pure reducer for a [CanvasState]. `canvas/updated` is a sparse merge — + * a presented field (title, status, url, availability) overwrites the + * corresponding [CanvasState] field while an absent field preserves the + * current value. `canvas/closeRequested` and `canvas/message` are pure + * client→host signals the host acts on out of band, mirroring how + * `terminal/input` is side-effect-only, so they return [state] unchanged. + * Actions belonging to other channels (or unknown variants) are no-ops. + */ +public fun canvasReducer(state: CanvasState, action: StateAction): CanvasState = when (action) { + is StateActionCanvasUpdated -> state.copy( + title = action.value.title ?: state.title, + status = action.value.status ?: state.status, + url = action.value.url ?: state.url, + availability = action.value.availability ?: state.availability, + ) + is StateActionCanvasCloseRequested -> state + is StateActionCanvasMessage -> state + else -> state +} 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 0bfff3a8..3fa2af3e 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 @@ -175,7 +175,17 @@ enum class ActionType { @SerialName("terminal/commandFinished") TERMINAL_COMMAND_FINISHED, @SerialName("resourceWatch/changed") - RESOURCE_WATCH_CHANGED + RESOURCE_WATCH_CHANGED, + @SerialName("session/canvasesChanged") + SESSION_CANVASES_CHANGED, + @SerialName("session/openCanvasesChanged") + SESSION_OPEN_CANVASES_CHANGED, + @SerialName("canvas/updated") + CANVAS_UPDATED, + @SerialName("canvas/closeRequested") + CANVAS_CLOSE_REQUESTED, + @SerialName("canvas/message") + CANVAS_MESSAGE } // ─── Action Infrastructure ────────────────────────────────────────────────── @@ -773,6 +783,24 @@ data class SessionServerToolsChangedAction( val tools: List ) +@Serializable +data class SessionCanvasesChangedAction( + val type: ActionType, + /** + * Updated canvas registry (full replacement). + */ + val canvases: List +) + +@Serializable +data class SessionOpenCanvasesChangedAction( + val type: ActionType, + /** + * Updated open-instance catalogue (full replacement). + */ + val openCanvases: List +) + @Serializable data class SessionActiveClientSetAction( val type: ActionType, @@ -1291,6 +1319,41 @@ data class ResourceWatchChangedAction( val changes: JsonElement ) +@Serializable +data class CanvasUpdatedAction( + val type: ActionType, + /** + * New title. Absent preserves the current title. + */ + val title: String? = null, + /** + * New provider-defined status. Absent preserves the current status. + */ + val status: String? = null, + /** + * New content address. Absent preserves the current url. + */ + val url: String? = null, + /** + * New availability. Absent preserves the current availability. + */ + val availability: CanvasAvailability? = null +) + +@Serializable +data class CanvasCloseRequestedAction( + val type: ActionType +) + +@Serializable +data class CanvasMessageAction( + val type: ActionType, + /** + * Opaque, provider-defined message payload. + */ + val payload: JsonElement +) + // ─── Partial Summary Types ────────────────────────────────────────────────── @Serializable @@ -1381,6 +1444,8 @@ sealed interface StateAction @JvmInline value class StateActionSessionActivityChanged(val value: SessionActivityChangedAction) : StateAction @JvmInline value class StateActionSessionChangesetsChanged(val value: SessionChangesetsChangedAction) : StateAction @JvmInline value class StateActionSessionServerToolsChanged(val value: SessionServerToolsChangedAction) : StateAction +@JvmInline value class StateActionSessionCanvasesChanged(val value: SessionCanvasesChangedAction) : StateAction +@JvmInline value class StateActionSessionOpenCanvasesChanged(val value: SessionOpenCanvasesChangedAction) : StateAction @JvmInline value class StateActionSessionActiveClientSet(val value: SessionActiveClientSetAction) : StateAction @JvmInline value class StateActionSessionActiveClientRemoved(val value: SessionActiveClientRemovedAction) : StateAction @JvmInline value class StateActionSessionInputNeededSet(val value: SessionInputNeededSetAction) : StateAction @@ -1426,6 +1491,9 @@ sealed interface StateAction @JvmInline value class StateActionTerminalCommandExecuted(val value: TerminalCommandExecutedAction) : StateAction @JvmInline value class StateActionTerminalCommandFinished(val value: TerminalCommandFinishedAction) : StateAction @JvmInline value class StateActionResourceWatchChanged(val value: ResourceWatchChangedAction) : StateAction +@JvmInline value class StateActionCanvasUpdated(val value: CanvasUpdatedAction) : StateAction +@JvmInline value class StateActionCanvasCloseRequested(val value: CanvasCloseRequestedAction) : StateAction +@JvmInline value class StateActionCanvasMessage(val value: CanvasMessageAction) : StateAction @JvmInline value class StateActionUnknown(val raw: JsonObject) : StateAction internal object StateActionSerializer : KSerializer { @@ -1471,6 +1539,8 @@ internal object StateActionSerializer : KSerializer { "session/activityChanged" -> StateActionSessionActivityChanged(input.json.decodeFromJsonElement(SessionActivityChangedAction.serializer(), element)) "session/changesetsChanged" -> StateActionSessionChangesetsChanged(input.json.decodeFromJsonElement(SessionChangesetsChangedAction.serializer(), element)) "session/serverToolsChanged" -> StateActionSessionServerToolsChanged(input.json.decodeFromJsonElement(SessionServerToolsChangedAction.serializer(), element)) + "session/canvasesChanged" -> StateActionSessionCanvasesChanged(input.json.decodeFromJsonElement(SessionCanvasesChangedAction.serializer(), element)) + "session/openCanvasesChanged" -> StateActionSessionOpenCanvasesChanged(input.json.decodeFromJsonElement(SessionOpenCanvasesChangedAction.serializer(), element)) "session/activeClientSet" -> StateActionSessionActiveClientSet(input.json.decodeFromJsonElement(SessionActiveClientSetAction.serializer(), element)) "session/activeClientRemoved" -> StateActionSessionActiveClientRemoved(input.json.decodeFromJsonElement(SessionActiveClientRemovedAction.serializer(), element)) "session/inputNeededSet" -> StateActionSessionInputNeededSet(input.json.decodeFromJsonElement(SessionInputNeededSetAction.serializer(), element)) @@ -1516,6 +1586,9 @@ internal object StateActionSerializer : KSerializer { "terminal/commandExecuted" -> StateActionTerminalCommandExecuted(input.json.decodeFromJsonElement(TerminalCommandExecutedAction.serializer(), element)) "terminal/commandFinished" -> StateActionTerminalCommandFinished(input.json.decodeFromJsonElement(TerminalCommandFinishedAction.serializer(), element)) "resourceWatch/changed" -> StateActionResourceWatchChanged(input.json.decodeFromJsonElement(ResourceWatchChangedAction.serializer(), element)) + "canvas/updated" -> StateActionCanvasUpdated(input.json.decodeFromJsonElement(CanvasUpdatedAction.serializer(), element)) + "canvas/closeRequested" -> StateActionCanvasCloseRequested(input.json.decodeFromJsonElement(CanvasCloseRequestedAction.serializer(), element)) + "canvas/message" -> StateActionCanvasMessage(input.json.decodeFromJsonElement(CanvasMessageAction.serializer(), element)) else -> StateActionUnknown(obj) } } @@ -1554,6 +1627,8 @@ internal object StateActionSerializer : KSerializer { is StateActionSessionActivityChanged -> output.json.encodeToJsonElement(SessionActivityChangedAction.serializer(), value.value) is StateActionSessionChangesetsChanged -> output.json.encodeToJsonElement(SessionChangesetsChangedAction.serializer(), value.value) is StateActionSessionServerToolsChanged -> output.json.encodeToJsonElement(SessionServerToolsChangedAction.serializer(), value.value) + is StateActionSessionCanvasesChanged -> output.json.encodeToJsonElement(SessionCanvasesChangedAction.serializer(), value.value) + is StateActionSessionOpenCanvasesChanged -> output.json.encodeToJsonElement(SessionOpenCanvasesChangedAction.serializer(), value.value) is StateActionSessionActiveClientSet -> output.json.encodeToJsonElement(SessionActiveClientSetAction.serializer(), value.value) is StateActionSessionActiveClientRemoved -> output.json.encodeToJsonElement(SessionActiveClientRemovedAction.serializer(), value.value) is StateActionSessionInputNeededSet -> output.json.encodeToJsonElement(SessionInputNeededSetAction.serializer(), value.value) @@ -1599,6 +1674,9 @@ internal object StateActionSerializer : KSerializer { is StateActionTerminalCommandExecuted -> output.json.encodeToJsonElement(TerminalCommandExecutedAction.serializer(), value.value) is StateActionTerminalCommandFinished -> output.json.encodeToJsonElement(TerminalCommandFinishedAction.serializer(), value.value) is StateActionResourceWatchChanged -> output.json.encodeToJsonElement(ResourceWatchChangedAction.serializer(), value.value) + is StateActionCanvasUpdated -> output.json.encodeToJsonElement(CanvasUpdatedAction.serializer(), value.value) + is StateActionCanvasCloseRequested -> output.json.encodeToJsonElement(CanvasCloseRequestedAction.serializer(), value.value) + is StateActionCanvasMessage -> output.json.encodeToJsonElement(CanvasMessageAction.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 26402250..27ceeaa5 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 @@ -195,7 +195,19 @@ 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 canvases and host client-declared canvas providers — it + * can render an opaque canvas URL in an isolated surface, and it can answer + * `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases + * it declares via {@link SessionActiveClient.canvasProviders}. + * + * Hosts SHOULD only populate {@link SessionState.canvases} / + * {@link SessionState.openCanvases} and only route canvas requests to a + * client that declared this capability. Clients that omit it see no canvas + * surface. See {@link /specification/canvas-channel | Canvas Channel}. + */ + val canvas: Map? = null ) @Serializable @@ -1118,6 +1130,142 @@ data class ChangesetOperationFollowUp( val external: Boolean? = null ) +@Serializable +data class CanvasOpenParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Provider-local canvas id to open. + */ + val canvasId: String, + /** + * Owning provider id. + */ + val extensionId: String, + /** + * Caller-minted handle for the new instance. + */ + val instanceId: String, + /** + * Open input, validated by the provider against its declared schema. + */ + val input: Map? = null +) + +@Serializable +data class CanvasOpenResult( + /** + * Initial content address for the instance (see {@link CanvasState.url}). + */ + val url: String? = null, + /** + * Initial title. + */ + val title: String? = null, + /** + * Initial provider-defined status. + */ + val status: String? = null +) + +@Serializable +data class CanvasInvokeActionParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Instance handle the action targets. + */ + val instanceId: String, + /** + * Provider-local canvas id of the instance. + */ + val canvasId: String, + /** + * Owning provider id. + */ + val extensionId: String, + /** + * Declared action name to invoke. + */ + val actionName: String, + /** + * Action input, validated by the provider against its declared schema. + */ + val input: Map? = null +) + +@Serializable +data class CanvasInvokeActionResult( + /** + * Opaque, provider-defined return value. + */ + val value: JsonElement? = null +) + +@Serializable +data class CanvasCloseParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Instance handle to close. + */ + val instanceId: String, + /** + * Provider-local canvas id of the instance. + */ + val canvasId: String, + /** + * Owning provider id. + */ + val extensionId: String +) + +@Serializable +data class CanvasReadResourceParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * An `ahp-canvas-content://` content URI to read. + */ + val uri: String +) + +@Serializable +data class CanvasReadResourceResult( + /** + * The resolved content parts, wrapped for forward compatibility. + */ + val contents: List +) + +@Serializable +data class CanvasResourceContent( + /** + * The content URI this part resolves. + */ + val uri: String, + /** + * MIME type of the content, when known. + */ + val mimeType: String? = null, + /** + * UTF-8 text content, for text payloads. + */ + val text: String? = null, + /** + * Base64-encoded content, for binary payloads. + */ + val blob: String? = null +) + // ─── ReconnectResult Union ────────────────────────────────────────────────── @Serializable(with = ReconnectResultSerializer::class) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt index 9aeea82f..8d9e0e43 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt @@ -57,6 +57,10 @@ object AhpErrorCodes { const val PERMISSION_DENIED: Int = -32009 /** The target resource already exists and the operation does not allow overwriting */ const val ALREADY_EXISTS: Int = -32010 + /** An optimistic-concurrency precondition failed: a request precondition token no longer matches the resource state */ + const val CONFLICT: Int = -32011 + /** A canvas provider request (canvasOpen, canvasInvokeAction, or canvasClose) failed; `data` carries a provider-defined `{ code, message }` */ + const val CANVAS_PROVIDER_ERROR: Int = -32012 } // ─── Error Detail Payloads ────────────────────────────────────────────────── @@ -90,3 +94,15 @@ data class UnsupportedProtocolVersionErrorData( */ val supportedVersions: List ) + +@Serializable +data class CanvasProviderErrorData( + /** + * Provider-defined error code identifying the failure. + */ + val code: String, + /** + * Human-readable error message. + */ + val message: String +) 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 4ca2b7de..771beed4 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 @@ -706,6 +706,42 @@ enum class ResourceChangeType { DELETED } +/** + * Availability of a canvas provider or open instance. + */ +@Serializable +enum class CanvasAvailability { + /** + * The provider is connected and can service requests for this canvas. + */ + @SerialName("ready") + READY, + /** + * The provider is temporarily unavailable (for example, a client provider + * that disconnected). The entry is retained so it can be restored when the + * provider reconnects; in-flight requests fail until then. + */ + @SerialName("stale") + STALE +} + +/** + * Discriminant for {@link CanvasProviderSource}. + */ +@Serializable +enum class CanvasProviderKind { + /** + * The canvas is provided by the host itself. + */ + @SerialName("server") + SERVER, + /** + * The canvas is provided by a connected client. + */ + @SerialName("client") + CLIENT +} + // ─── State Types ──────────────────────────────────────────────────────────── @Serializable @@ -1332,6 +1368,29 @@ data class SessionState( * once the underlying request resolves. */ val inputNeeded: List? = null, + /** + * Aggregated canvas registry currently exposed to the agent — the union of + * every connected provider (server-side and client-declared). Each entry + * describes a canvas the agent can open; the host folds + * {@link SessionActiveClient.canvasProviders | client-declared providers} + * into this list alongside its own server-side providers. + * + * Full-replacement via `session/canvasesChanged`. Populated only when at + * least one connected client declared {@link ClientCapabilities.canvas}; + * absent for sessions with no canvas surface. See + * {@link /specification/canvas-channel | Canvas Channel}. + */ + val canvases: List? = null, + /** + * Lightweight catalogue of currently-open canvas instances. Each entry + * carries the instance's `ahp-canvas:/` channel URI so a subscriber can + * subscribe to the full {@link CanvasState} and render it — analogous to how + * {@link RootState.terminals} catalogues live terminals whose full state + * lives on each terminal channel. + * + * Full-replacement via `session/openCanvasesChanged`. + */ + val openCanvases: List? = null, /** * Additional provider-specific metadata for this session. * @@ -1365,7 +1424,20 @@ data class SessionActiveClient( * plugins in memory and rely on the host to expand them into concrete * children inside {@link SessionState.customizations}. */ - val customizations: List? = null + val customizations: List? = null, + /** + * Canvas declarations this client contributes as a provider. Published + * atomically with the rest of the active-client entry via + * `session/activeClientSet` — exactly like {@link tools} — so there is no + * separate canvas-provider change action. The host folds these into + * {@link SessionState.canvases} with + * {@link SessionCanvasDeclaration.source | `source`} set to + * `{ kind: 'client', clientId }` and routes + * `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back + * to this client. Only meaningful for a client that declared + * {@link ClientCapabilities.canvas}. + */ + val canvasProviders: List? = null ) @Serializable @@ -3998,6 +4070,185 @@ data class ResourceChange( val type: ResourceChangeType ) +@Serializable +data class SessionCanvasAction( + /** + * Action name, unique within the owning `(extensionId, canvasId)`. + */ + val name: String, + /** + * Human-readable description of what the action does. + */ + val description: String? = null, + /** + * JSON Schema for the action's input. Opaque to AHP; mirrors the + * {@link ToolDefinition.inputSchema} shape. + */ + val inputSchema: Map? = null +) + +@Serializable +data class SessionCanvasDeclaration( + /** + * Owning provider id. Stable across declarations and instances. + */ + val extensionId: String, + /** + * Human-readable provider name. + */ + val extensionName: String? = null, + /** + * Provider-local canvas id. Unique within `extensionId`. + */ + val canvasId: String, + /** + * Human-readable canvas name. + */ + val displayName: String, + /** + * Human-readable description of the canvas. + */ + val description: String, + /** + * JSON Schema for the canvas's open input. Opaque to AHP; mirrors the + * {@link ToolDefinition.inputSchema} shape. + */ + val inputSchema: Map? = null, + /** + * Actions this canvas exposes to the agent. + */ + val actions: List? = null, + /** + * Where the declaration came from — for routing and cleanup. + */ + val source: CanvasProviderSource +) + +@Serializable +data class ClientCanvasDeclaration( + /** + * Provider-local canvas id, unique within the publishing client. + */ + val canvasId: String, + /** + * Human-readable canvas name. + */ + val displayName: String, + /** + * Human-readable description of the canvas. + */ + val description: String, + /** + * JSON Schema for the canvas's open input. Opaque to AHP. + */ + val inputSchema: Map? = null, + /** + * Actions this canvas exposes to the agent. + */ + val actions: List? = null +) + +@Serializable +data class OpenCanvasRef( + /** + * Server-assigned instance handle, unique within the session. + */ + val instanceId: String, + /** + * The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load + * the full {@link CanvasState}. + */ + val channel: String, + /** + * Provider-local canvas id this instance was opened from. + */ + val canvasId: String, + /** + * Owning provider id. + */ + val extensionId: String, + /** + * Human-readable provider name. + */ + val extensionName: String? = null, + /** + * Current instance title, mirrored from {@link CanvasState.title}. + */ + val title: String? = null, + /** + * Whether the instance's provider is currently available. + */ + val availability: CanvasAvailability +) + +@Serializable +data class CanvasServerProviderSource( + val kind: CanvasProviderKind +) + +@Serializable +data class CanvasClientProviderSource( + val kind: CanvasProviderKind, + /** + * `clientId` of the providing client (matches `initialize`). + */ + val clientId: String +) + +@Serializable +data class CanvasState( + /** + * Server-assigned instance handle, unique within the session. + */ + val instanceId: String, + /** + * Provider-local canvas id this instance was opened from. + */ + val canvasId: String, + /** + * Owning provider id. + */ + val extensionId: String, + /** + * Human-readable provider name. + */ + val extensionName: String? = null, + /** + * Human-readable canvas name. + */ + val displayName: String? = null, + /** + * Input the agent supplied when opening the instance. Retained so the + * instance can be resumed or rebound after a reconnect. + */ + val input: Map? = null, + /** + * Current instance title. + */ + val title: String? = null, + /** + * Provider-defined status string (opaque to AHP). + */ + val status: String? = null, + /** + * Renderer-targeted address for the opaque canvas content — either a + * directly-loadable URL (`https:`, an in-process scheme, `http://localhost`) + * or a channel-served `ahp-canvas-content://` address the + * renderer resolves over this channel with `canvasReadResource`. The + * renderer dispatches on the scheme and enforces its URL policy. See + * {@link /specification/canvas-channel | Canvas Channel}. + */ + val url: String? = null, + /** + * Whether this instance's provider is currently available. + */ + val availability: CanvasAvailability, + /** + * Which provider owns the callbacks (`canvasOpen` / … ) for this instance. + */ + val provider: CanvasProviderSource +) + // ─── Discriminated Unions ─────────────────────────────────────────────────── @Serializable(with = ChatOriginSerializer::class) @@ -4895,6 +5146,55 @@ internal object SessionInputRequestSerializer : KSerializer } } +@Serializable(with = CanvasProviderSourceSerializer::class) +sealed interface CanvasProviderSource + +@JvmInline +value class CanvasProviderSourceServer(val value: CanvasServerProviderSource) : CanvasProviderSource +@JvmInline +value class CanvasProviderSourceClient(val value: CanvasClientProviderSource) : CanvasProviderSource +/** + * Forward-compat catch-all for unknown CanvasProviderSource 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 CanvasProviderSourceUnknown(val raw: JsonObject) : CanvasProviderSource + +internal object CanvasProviderSourceSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CanvasProviderSource") + + override fun deserialize(decoder: Decoder): CanvasProviderSource { + val input = decoder as? JsonDecoder + ?: error("CanvasProviderSource can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CanvasProviderSource") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: return CanvasProviderSourceUnknown(obj) + return when (discriminant) { + "server" -> CanvasProviderSourceServer(input.json.decodeFromJsonElement(CanvasServerProviderSource.serializer(), element)) + "client" -> CanvasProviderSourceClient(input.json.decodeFromJsonElement(CanvasClientProviderSource.serializer(), element)) + else -> CanvasProviderSourceUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: CanvasProviderSource) { + val output = encoder as? JsonEncoder + ?: error("CanvasProviderSource can only be serialized to JSON") + val element: JsonElement = when (value) { + is CanvasProviderSourceServer -> output.json.encodeToJsonElement(CanvasServerProviderSource.serializer(), value.value) + is CanvasProviderSourceClient -> output.json.encodeToJsonElement(CanvasClientProviderSource.serializer(), value.value) + is CanvasProviderSourceUnknown -> value.raw + } + output.encodeJsonElement(element) + } +} + @Serializable(with = ToolResultContentSerializer::class) sealed interface ToolResultContent { @JvmInline value class Text(val value: ToolResultTextContent) : ToolResultContent @@ -4954,7 +5254,7 @@ internal object ToolResultContentSerializer : KSerializer { /** * The state payload of a snapshot — root, session, chat, terminal, changeset, - * resource-watch, or annotations state. + * resource-watch, canvas, or annotations state. */ @Serializable(with = SnapshotStateSerializer::class) sealed interface SnapshotState { @@ -4964,6 +5264,7 @@ sealed interface SnapshotState { @JvmInline value class Terminal(val value: TerminalState) : SnapshotState @JvmInline value class Changeset(val value: ChangesetState) : SnapshotState @JvmInline value class ResourceWatch(val value: ResourceWatchState) : SnapshotState + @JvmInline value class Canvas(val value: CanvasState) : SnapshotState @JvmInline value class Annotations(val value: AnnotationsState) : SnapshotState } @@ -4980,7 +5281,8 @@ internal object SnapshotStateSerializer : KSerializer { // Try the most distinctive shape first. SessionState has required // `lifecycle`; ChatState has required `turns`; ChangesetState has // required `status` + `files`; ResourceWatchState has required - // `root` + `recursive`; AnnotationsState has required `annotations` + // `root` + `recursive`; CanvasState has required `canvasId` + + // `provider`; AnnotationsState has required `annotations` // (checked after session, whose optional annotations summary reuses the // key); TerminalState has required `content`; RootState is the // catch-all. @@ -4991,6 +5293,8 @@ internal object SnapshotStateSerializer : KSerializer { SnapshotState.Changeset(input.json.decodeFromJsonElement(ChangesetState.serializer(), element)) obj.containsKey("root") && obj.containsKey("recursive") -> SnapshotState.ResourceWatch(input.json.decodeFromJsonElement(ResourceWatchState.serializer(), element)) + obj.containsKey("canvasId") && obj.containsKey("provider") -> + SnapshotState.Canvas(input.json.decodeFromJsonElement(CanvasState.serializer(), element)) obj.containsKey("annotations") -> SnapshotState.Annotations(input.json.decodeFromJsonElement(AnnotationsState.serializer(), element)) obj.containsKey("content") -> @@ -5009,6 +5313,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Terminal -> output.json.encodeToJsonElement(TerminalState.serializer(), value.value) is SnapshotState.Changeset -> output.json.encodeToJsonElement(ChangesetState.serializer(), value.value) is SnapshotState.ResourceWatch -> output.json.encodeToJsonElement(ResourceWatchState.serializer(), value.value) + is SnapshotState.Canvas -> output.json.encodeToJsonElement(CanvasState.serializer(), value.value) is SnapshotState.Annotations -> output.json.encodeToJsonElement(AnnotationsState.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 c1a4c3cf..666c8aab 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.5.1" +public const val PROTOCOL_VERSION: String = "0.6.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,6 +16,7 @@ public const val PROTOCOL_VERSION: String = "0.5.1" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( + "0.6.0", "0.5.1", "0.5.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 37cf486b..84b5f311 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt @@ -3,6 +3,7 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.ChatState import com.microsoft.agenthostprotocol.generated.ChangesetState import com.microsoft.agenthostprotocol.generated.AnnotationsState +import com.microsoft.agenthostprotocol.generated.CanvasState import com.microsoft.agenthostprotocol.generated.ResourceWatchState import com.microsoft.agenthostprotocol.generated.RootState import com.microsoft.agenthostprotocol.generated.SessionState @@ -216,6 +217,18 @@ class FixtureDrivenReducerTest { }, ) + "canvas" -> compareFixture( + file = file, + initial = initial, + expected = expected, + serializer = CanvasState.serializer(), + run = { state -> + var s = state + for (action in actions) s = canvasReducer(s, action) + s + }, + ) + else -> fail("${file.name}: unsupported reducer '$reducer'") } } diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md index 68deb44e..9d21090c 100644 --- a/clients/rust/CHANGELOG.md +++ b/clients/rust/CHANGELOG.md @@ -40,6 +40,16 @@ matching `## [X.Y.Z]` heading is missing from this file. lifecycle state. - Optional `model` and `tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +- Canvas channel support: the per-instance `CanvasState` plus the + `StateAction::CanvasUpdated` / `StateAction::CanvasCloseRequested` / + `StateAction::CanvasMessage` actions, the `StateAction::SessionCanvasesChanged` + / `StateAction::SessionOpenCanvasesChanged` session actions, and the canvas + discovery types (`SessionCanvasDeclaration`, `ClientCanvasDeclaration`, + `OpenCanvasRef`, `CanvasProviderSource`) on `SessionState.canvases` / + `SessionState.open_canvases`. Adds the `ClientCapabilities.canvas` capability, + the `canvasOpen` / `canvasInvokeAction` / `canvasClose` / `canvasReadResource` + methods, and the `CanvasProviderError` error. The session reducer replaces the + canvas registry/catalogue and the canvas reducer sparse-merges `canvas/updated`. ### Changed diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 2e79251e..539427a1 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -13,13 +13,14 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; #[allow(unused_imports)] use crate::state::{ - AgentInfo, AgentSelection, Annotation, AnnotationEntry, Changeset, ChangesetFile, - ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, + AgentInfo, AgentSelection, Annotation, AnnotationEntry, CanvasAvailability, Changeset, + ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, Customization, ErrorInfo, McpServerState, Message, ModelSelection, - PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, TerminalClaim, - TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, - ToolCallContributor, ToolCallResult, ToolDefinition, ToolResultContent, UsageInfo, + OpenCanvasRef, PendingMessageKind, ResponsePart, SessionActiveClient, SessionCanvasDeclaration, + SessionInputRequest, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, + ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, ToolDefinition, + ToolResultContent, UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -177,6 +178,16 @@ pub enum ActionType { TerminalCommandFinished, #[serde(rename = "resourceWatch/changed")] ResourceWatchChanged, + #[serde(rename = "session/canvasesChanged")] + SessionCanvasesChanged, + #[serde(rename = "session/openCanvasesChanged")] + SessionOpenCanvasesChanged, + #[serde(rename = "canvas/updated")] + CanvasUpdated, + #[serde(rename = "canvas/closeRequested")] + CanvasCloseRequested, + #[serde(rename = "canvas/message")] + CanvasMessage, } // ─── Action Envelope ───────────────────────────────────────────────── @@ -781,6 +792,31 @@ pub struct SessionServerToolsChangedAction { pub tools: Vec, } +/// The aggregated canvas registry for this session changed. +/// +/// Full-replacement semantics: the `canvases` array replaces +/// {@link SessionState.canvases} entirely, mirroring +/// `session/serverToolsChanged`. The host republishes the union of every +/// connected provider (server-side and client-declared) whenever it changes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCanvasesChangedAction { + /// Updated canvas registry (full replacement). + pub canvases: Vec, +} + +/// The catalogue of open canvas instances for this session changed. +/// +/// Full-replacement semantics: the `openCanvases` array replaces +/// {@link SessionState.openCanvases} entirely. The host republishes the +/// catalogue as instances open and close. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionOpenCanvasesChangedAction { + /// Updated open-instance catalogue (full replacement). + pub open_canvases: Vec, +} + /// An active client for this session was added or updated. /// /// Upsert semantics keyed by {@link SessionActiveClient.clientId | `clientId`}: @@ -1461,6 +1497,58 @@ pub struct ResourceWatchChangedAction { pub changes: AnyValue, } +/// The canvas instance's presentation state changed. +/// +/// Sparse-merge semantics: each present field overwrites the corresponding +/// {@link CanvasState} field, and an absent field preserves the current value. +/// There is no clear-to-absent via this action — that three-state distinction +/// cannot survive JSON transport uniformly across languages, so a provider that +/// needs to reset a field re-publishes it, and a full reset arrives as a fresh +/// {@link CanvasState} snapshot on (re)subscribe. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CanvasUpdatedAction { + /// New title. Absent preserves the current title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// New provider-defined status. Absent preserves the current status. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// New content address. Absent preserves the current url. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// New availability. Absent preserves the current availability. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub availability: Option, +} + +/// The user asked to close this canvas (e.g. hit the ✕ on the surface). +/// +/// A pure client→host signal with a no-op reducer, mirroring how +/// `terminal/input` is a side-effect-only client action. The host runs the +/// close flow in response — resolving `canvasClose` against the provider and +/// dropping the instance from {@link SessionState.openCanvases} — rather than +/// the reducer mutating channel state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCloseRequestedAction {} + +/// An opaque message relayed between the rendered canvas View and the +/// instance's provider — the relay-carried analogue of a `postMessage` bridge. +/// +/// Bidirectional: a client dispatches it to carry a View→provider message, and +/// the host emits it to carry a provider→View message (routed to the provider +/// resolved for the instance, or handled host-internally for a server-side +/// provider). Like `terminal/input` and {@link CanvasCloseRequestedAction} it +/// is a pure signal with a no-op reducer, so it never bloats channel state. See +/// {@link /specification/canvas-channel | Canvas Channel}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasMessageAction { + /// Opaque, provider-defined message payload. + pub payload: AnyValue, +} + // ─── Partial Summaries ──────────────────────────────────────────────── /// Partial equivalent of ChatSummary — every field is optional for delta updates. @@ -1569,6 +1657,10 @@ pub enum StateAction { SessionChangesetsChanged(SessionChangesetsChangedAction), #[serde(rename = "session/serverToolsChanged")] SessionServerToolsChanged(SessionServerToolsChangedAction), + #[serde(rename = "session/canvasesChanged")] + SessionCanvasesChanged(SessionCanvasesChangedAction), + #[serde(rename = "session/openCanvasesChanged")] + SessionOpenCanvasesChanged(SessionOpenCanvasesChangedAction), #[serde(rename = "session/activeClientSet")] SessionActiveClientSet(SessionActiveClientSetAction), #[serde(rename = "session/activeClientRemoved")] @@ -1657,6 +1749,12 @@ pub enum StateAction { TerminalCommandFinished(TerminalCommandFinishedAction), #[serde(rename = "resourceWatch/changed")] ResourceWatchChanged(ResourceWatchChangedAction), + #[serde(rename = "canvas/updated")] + CanvasUpdated(CanvasUpdatedAction), + #[serde(rename = "canvas/closeRequested")] + CanvasCloseRequested(CanvasCloseRequestedAction), + #[serde(rename = "canvas/message")] + CanvasMessage(CanvasMessageAction), /// 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 b55dd88f..5acc0d30 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -183,6 +183,17 @@ 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 canvases and host client-declared canvas providers — it + /// can render an opaque canvas URL in an isolated surface, and it can answer + /// `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases + /// it declares via {@link SessionActiveClient.canvasProviders}. + /// + /// Hosts SHOULD only populate {@link SessionState.canvases} / + /// {@link SessionState.openCanvases} and only route canvas requests to a + /// client that declared this capability. Clients that omit it see no canvas + /// surface. See {@link /specification/canvas-channel | Canvas Channel}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas: Option, } /// Re-establishes a dropped connection. The server replays missed actions or @@ -1146,6 +1157,151 @@ pub struct ChangesetOperationFollowUp { pub external: Option, } +/// Opens a canvas instance against its provider. +/// +/// Sent by the host to the client that declared the target canvas via +/// {@link SessionActiveClient.canvasProviders} (a client that also declared +/// {@link ClientCapabilities.canvas}). For a server-side provider the host +/// resolves the open host-internally and emits no request. The provider returns +/// the initial render target and presentation fields, which the host folds into +/// the new instance's {@link CanvasState}. +/// +/// Mirrors the `resource*` precedent: registered in `ServerCommandMap` and +/// mirrored in `CommandMap` for symmetry. A client normally never initiates it — +/// the host is not a canvas provider — and a receiver SHOULD reject a request +/// whose target is not one of its declared providers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Provider-local canvas id to open. + pub canvas_id: String, + /// Owning provider id. + pub extension_id: String, + /// Caller-minted handle for the new instance. + pub instance_id: String, + /// Open input, validated by the provider against its declared schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Result of the `canvasOpen` command. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenResult { + /// Initial content address for the instance (see {@link CanvasState.url}). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Initial title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Initial provider-defined status. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +/// Invokes one of a canvas's declared actions against its provider. +/// +/// Sent by the host to the providing client (or resolved host-internally for a +/// server-side provider) when the agent invokes a +/// {@link SessionCanvasAction | declared action} on an open instance. The +/// provider returns an opaque, provider-defined value. Registered symmetrically +/// with the rest of the provider family (see {@link CanvasOpenParams}). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Instance handle the action targets. + pub instance_id: String, + /// Provider-local canvas id of the instance. + pub canvas_id: String, + /// Owning provider id. + pub extension_id: String, + /// Declared action name to invoke. + pub action_name: String, + /// Action input, validated by the provider against its declared schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Result of the `canvasInvokeAction` command. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionResult { + /// Opaque, provider-defined return value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +/// Closes a canvas instance against its provider. +/// +/// Sent by the host to the providing client (or resolved host-internally for a +/// server-side provider) as part of the close flow — typically after a client +/// dispatches `canvas/closeRequested`. The host then drops the instance from +/// {@link SessionState.openCanvases}. Registered symmetrically with the rest of +/// the provider family (see {@link CanvasOpenParams}). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCloseParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Instance handle to close. + pub instance_id: String, + /// Provider-local canvas id of the instance. + pub canvas_id: String, + /// Owning provider id. + pub extension_id: String, +} + +/// Reads channel-served canvas content by `ahp-canvas-content:` URI. +/// +/// A client → host request, modeled on MCP's `resources/read`. When a canvas's +/// {@link CanvasState.url} (or a sub-resource the rendered document references) +/// is an `ahp-canvas-content://` address, the renderer cannot +/// dial the host directly — for example in a relayed deployment behind a broker +/// — so it resolves the bytes over the instance's existing `ahp-canvas:/` +/// channel with this request instead of loading a network URL. The `` +/// segment of the URI identifies which canvas channel to read from. See +/// {@link /specification/canvas-channel | Canvas Channel}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReadResourceParams { + /// Channel URI this command targets. + pub channel: Uri, + /// An `ahp-canvas-content://` content URI to read. + pub uri: String, +} + +/// Result of the `canvasReadResource` command. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReadResourceResult { + /// The resolved content parts, wrapped for forward compatibility. + pub contents: Vec, +} + +/// One resolved piece of channel-served canvas content. +/// +/// Carries exactly one of {@link text} (text payloads) or {@link blob} +/// (base64-encoded binary payloads). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasResourceContent { + /// The content URI this part resolves. + pub uri: String, + /// MIME type of the content, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// UTF-8 text content, for text payloads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + /// Base64-encoded content, for binary payloads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blob: Option, +} + // ─── ReconnectResult Union ──────────────────────────────────────────── /// Result of the `reconnect` command. diff --git a/clients/rust/crates/ahp-types/src/errors.rs b/clients/rust/crates/ahp-types/src/errors.rs index 1394794b..2351e68b 100644 --- a/clients/rust/crates/ahp-types/src/errors.rs +++ b/clients/rust/crates/ahp-types/src/errors.rs @@ -55,6 +55,8 @@ pub mod ahp_error_codes { pub const ALREADY_EXISTS: i32 = -32010; /// An optimistic-concurrency precondition failed: a request's precondition token (e.g. `ResourceWriteParams.if_match`) no longer matches the resource's current state. pub const CONFLICT: i32 = -32011; + /// A canvas provider request (`canvasOpen`, `canvasInvokeAction`, or `canvasClose`) failed; the error `data` carries a provider-defined `{ code, message }`. + pub const CANVAS_PROVIDER_ERROR: i32 = -32012; } /// Type alias: AHP application error code. @@ -93,3 +95,14 @@ pub struct UnsupportedProtocolVersionErrorData { /// SemVer range constraint (e.g. `">=0.1.0 <0.3.0"` or `"^0.2.0"`). pub supported_versions: Vec, } + +/// Details carried in the `data` field of a `CanvasProviderError` (-32012) +/// error. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderErrorData { + /// Provider-defined error code identifying the failure. + pub code: String, + /// Human-readable error message. + pub message: String, +} diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 2e8b15d8..17b80697 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -555,6 +555,30 @@ pub enum ResourceChangeType { Deleted, } +/// Availability of a canvas provider or open instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CanvasAvailability { + /// The provider is connected and can service requests for this canvas. + #[serde(rename = "ready")] + Ready, + /// The provider is temporarily unavailable (for example, a client provider + /// that disconnected). The entry is retained so it can be restored when the + /// provider reconnects; in-flight requests fail until then. + #[serde(rename = "stale")] + Stale, +} + +/// Discriminant for {@link CanvasProviderSource}. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CanvasProviderKind { + /// The canvas is provided by the host itself. + #[serde(rename = "server")] + Server, + /// The canvas is provided by a connected client. + #[serde(rename = "client")] + Client, +} + // ─── Structs ────────────────────────────────────────────────────────── /// An optionally-sized icon that can be displayed in a user interface. @@ -1152,6 +1176,27 @@ pub struct SessionState { /// once the underlying request resolves. #[serde(default, skip_serializing_if = "Option::is_none")] pub input_needed: Option>, + /// Aggregated canvas registry currently exposed to the agent — the union of + /// every connected provider (server-side and client-declared). Each entry + /// describes a canvas the agent can open; the host folds + /// {@link SessionActiveClient.canvasProviders | client-declared providers} + /// into this list alongside its own server-side providers. + /// + /// Full-replacement via `session/canvasesChanged`. Populated only when at + /// least one connected client declared {@link ClientCapabilities.canvas}; + /// absent for sessions with no canvas surface. See + /// {@link /specification/canvas-channel | Canvas Channel}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option>, + /// Lightweight catalogue of currently-open canvas instances. Each entry + /// carries the instance's `ahp-canvas:/` channel URI so a subscriber can + /// subscribe to the full {@link CanvasState} and render it — analogous to how + /// {@link RootState.terminals} catalogues live terminals whose full state + /// lives on each terminal channel. + /// + /// Full-replacement via `session/openCanvasesChanged`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_canvases: Option>, /// Additional provider-specific metadata for this session. /// /// Clients MAY look for well-known keys here to provide enhanced UI. @@ -1184,6 +1229,18 @@ pub struct SessionActiveClient { /// children inside {@link SessionState.customizations}. #[serde(default, skip_serializing_if = "Option::is_none")] pub customizations: Option>, + /// Canvas declarations this client contributes as a provider. Published + /// atomically with the rest of the active-client entry via + /// `session/activeClientSet` — exactly like {@link tools} — so there is no + /// separate canvas-provider change action. The host folds these into + /// {@link SessionState.canvases} with + /// {@link SessionCanvasDeclaration.source | `source`} set to + /// `{ kind: 'client', clientId }` and routes + /// `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back + /// to this client. Only meaningful for a client that declared + /// {@link ClientCapabilities.canvas}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_providers: Option>, } /// A user-input elicitation surfaced at the session level, mirroring one entry @@ -3599,6 +3656,166 @@ pub struct ResourceChange { pub r#type: ResourceChangeType, } +/// One named action a canvas exposes to the agent, mirroring the shape of a +/// {@link ToolDefinition} entry. Unique within its owning +/// `(extensionId, canvasId)`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCanvasAction { + /// Action name, unique within the owning `(extensionId, canvasId)`. + pub name: String, + /// Human-readable description of what the action does. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema for the action's input. Opaque to AHP; mirrors the + /// {@link ToolDefinition.inputSchema} shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, +} + +/// One entry in the aggregated {@link SessionState.canvases} registry — a canvas +/// the agent can open, contributed by a server-side or client-declared provider. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCanvasDeclaration { + /// Owning provider id. Stable across declarations and instances. + pub extension_id: String, + /// Human-readable provider name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Provider-local canvas id. Unique within `extensionId`. + pub canvas_id: String, + /// Human-readable canvas name. + pub display_name: String, + /// Human-readable description of the canvas. + pub description: String, + /// JSON Schema for the canvas's open input. Opaque to AHP; mirrors the + /// {@link ToolDefinition.inputSchema} shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Actions this canvas exposes to the agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option>, + /// Where the declaration came from — for routing and cleanup. + pub source: CanvasProviderSource, +} + +/// The lighter declaration shape a client publishes on +/// {@link SessionActiveClient.canvasProviders}. The host derives the +/// `extensionId` and {@link SessionCanvasDeclaration.source | `source`} when +/// folding it into {@link SessionState.canvases}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCanvasDeclaration { + /// Provider-local canvas id, unique within the publishing client. + pub canvas_id: String, + /// Human-readable canvas name. + pub display_name: String, + /// Human-readable description of the canvas. + pub description: String, + /// JSON Schema for the canvas's open input. Opaque to AHP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Actions this canvas exposes to the agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option>, +} + +/// A lightweight catalogue entry for one open canvas instance, surfaced on +/// {@link SessionState.openCanvases}. The authoritative, mutable per-instance +/// state lives on the instance's own {@link CanvasState} channel; this entry +/// exists so a subscriber can discover the channel URI and render it without +/// subscribing to every instance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCanvasRef { + /// Server-assigned instance handle, unique within the session. + pub instance_id: String, + /// The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load + /// the full {@link CanvasState}. + pub channel: Uri, + /// Provider-local canvas id this instance was opened from. + pub canvas_id: String, + /// Owning provider id. + pub extension_id: String, + /// Human-readable provider name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Current instance title, mirrored from {@link CanvasState.title}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Whether the instance's provider is currently available. + pub availability: CanvasAvailability, +} + +/// A canvas provided by the host. Carries no `clientId` — server-side provider +/// requests are resolved host-internally rather than routed to a peer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasServerProviderSource {} + +/// A canvas provided by a connected client. The host routes +/// `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas +/// to the identified client. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasClientProviderSource { + /// `clientId` of the providing client (matches `initialize`). + pub client_id: String, +} + +/// Full state for a single open canvas instance, delivered when a client +/// subscribes to the instance's `ahp-canvas:/` channel. +/// +/// One channel exists per open instance — the same "one channel per resource" +/// convention used by terminals, changesets, and resource watches. The +/// lightweight catalogue entry that advertises this channel is +/// {@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the +/// authoritative, mutable per-instance view a renderer reads. +/// +/// Rendering is state-driven: a client renders the canvas by reading +/// {@link url} and resolving it per the renderer's URL policy — directly for a +/// reachable address, or over this channel via `canvasReadResource` for an +/// `ahp-canvas-content:` address. It never receives a "render this" request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasState { + /// Server-assigned instance handle, unique within the session. + pub instance_id: String, + /// Provider-local canvas id this instance was opened from. + pub canvas_id: String, + /// Owning provider id. + pub extension_id: String, + /// Human-readable provider name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Human-readable canvas name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Input the agent supplied when opening the instance. Retained so the + /// instance can be resumed or rebound after a reconnect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Current instance title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Provider-defined status string (opaque to AHP). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Renderer-targeted address for the opaque canvas content — either a + /// directly-loadable URL (`https:`, an in-process scheme, `http://localhost`) + /// or a channel-served `ahp-canvas-content://` address the + /// renderer resolves over this channel with `canvasReadResource`. The + /// renderer dispatches on the scheme and enforces its URL policy. See + /// {@link /specification/canvas-channel | Canvas Channel}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Whether this instance's provider is currently available. + pub availability: CanvasAvailability, + /// Which provider owns the callbacks (`canvasOpen` / … ) for this instance. + pub provider: CanvasProviderSource, +} + // ─── Discriminated Unions ───────────────────────────────────────────── /// How a chat came into existence. @@ -3920,13 +4137,28 @@ pub enum SessionInputRequest { Unknown(serde_json::Value), } +/// Where a canvas declaration came from — used for request routing and cleanup when its provider disconnects. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum CanvasProviderSource { + #[serde(rename = "server")] + Server(CanvasServerProviderSource), + #[serde(rename = "client")] + Client(CanvasClientProviderSource), + /// 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 — root, session, chat, terminal, -/// changeset, resource-watch, or annotations state. +/// changeset, resource-watch, canvas, or annotations state. /// /// Deserialized by trying session first (has required `lifecycle`), then /// chat (has required `turns`), then terminal (has required `content`), /// then changeset (has required `status` and `files`), then resource-watch -/// (has required `root` and `recursive`), then annotations (has required +/// (has required `root` and `recursive`), then canvas (has required +/// `canvasId` and `provider`), then annotations (has required /// `annotations`), then root. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] @@ -3936,6 +4168,7 @@ pub enum SnapshotState { Terminal(Box), Changeset(Box), ResourceWatch(Box), + Canvas(Box), Annotations(Box), Root(Box), } diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 5b04e5bc..8db522a4 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.5.1"; +pub const PROTOCOL_VERSION: &str = "0.6.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,4 +13,4 @@ pub const PROTOCOL_VERSION: &str = "0.5.1"; /// 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.5.1", "0.5.0"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["0.6.0", "0.5.1", "0.5.0"]; 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 7288894b..fc682c8a 100644 --- a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs @@ -37,13 +37,14 @@ use std::collections::HashMap; use ahp_types::actions::ActionEnvelope; use ahp_types::common::ROOT_RESOURCE_URI; use ahp_types::state::{ - AnnotationsState, ChangesetState, ChatState, ResourceWatchState, RootState, SessionState, - SnapshotState, TerminalState, + AnnotationsState, CanvasState, ChangesetState, ChatState, ResourceWatchState, RootState, + SessionState, SnapshotState, TerminalState, }; use crate::hosts::{HostId, HostSubscriptionEvent}; use crate::reducers::{ - apply_action_to_chat, apply_action_to_root, apply_action_to_session, apply_action_to_terminal, + apply_action_to_canvas, apply_action_to_chat, apply_action_to_root, apply_action_to_session, + apply_action_to_terminal, }; use crate::SubscriptionEvent; @@ -92,6 +93,7 @@ pub struct MultiHostStateMirror { changesets: HashMap, annotations: HashMap, resource_watches: HashMap, + canvases: HashMap, } impl MultiHostStateMirror { @@ -135,6 +137,11 @@ impl MultiHostStateMirror { &self.resource_watches } + /// Borrow the canvas states map 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 @@ -176,6 +183,10 @@ impl MultiHostStateMirror { } if let Some(terminal) = self.terminals.get_mut(&key) { apply_action_to_terminal(terminal, &envelope.action); + return; + } + 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 @@ -208,6 +219,9 @@ impl MultiHostStateMirror { SnapshotState::ResourceWatch(state) => { self.resource_watches.insert(key, state.as_ref().clone()); } + SnapshotState::Canvas(state) => { + self.canvases.insert(key, state.as_ref().clone()); + } SnapshotState::Annotations(state) => { self.annotations.insert(key, state.as_ref().clone()); } @@ -215,7 +229,7 @@ impl MultiHostStateMirror { } /// Drop every slot keyed under `host` — root state, sessions, - /// terminals, changesets, resource watches, and annotations. + /// terminals, changesets, resource watches, canvases, and annotations. pub fn reset_host(&mut self, host: &HostId) { self.root_states.remove(host); self.sessions.retain(|key, _| &key.host_id != host); @@ -224,6 +238,7 @@ impl MultiHostStateMirror { self.changesets.retain(|key, _| &key.host_id != host); self.annotations.retain(|key, _| &key.host_id != host); self.resource_watches.retain(|key, _| &key.host_id != host); + self.canvases.retain(|key, _| &key.host_id != host); } /// Drop every host's state. @@ -235,5 +250,6 @@ impl MultiHostStateMirror { self.changesets.clear(); self.annotations.clear(); self.resource_watches.clear(); + self.canvases.clear(); } } diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index afacf342..688d154e 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -3,8 +3,9 @@ //! Reducers mutate state in place and return a [`ReduceOutcome`]. Use //! [`apply_action_to_root`], [`apply_action_to_session`], //! [`apply_action_to_chat`], [`apply_action_to_terminal`], -//! [`apply_action_to_changeset`], [`apply_action_to_annotations`], and -//! [`apply_action_to_resource_watch`] to dispatch any [`StateAction`] +//! [`apply_action_to_changeset`], [`apply_action_to_annotations`], +//! [`apply_action_to_resource_watch`], and [`apply_action_to_canvas`] to +//! dispatch any [`StateAction`] //! against the matching scope; unrelated actions short-circuit as //! [`ReduceOutcome::OutOfScope`] so a client holding every state tree can //! blindly fan each action out. @@ -57,7 +58,7 @@ use ahp_types::actions::{ ChatToolCallResultConfirmedAction, ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, + ActiveTurn, AnnotationsState, CanvasState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, ErrorInfo, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, @@ -650,6 +651,14 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - state.server_tools = Some(a.tools.clone()); ReduceOutcome::Applied } + StateAction::SessionCanvasesChanged(a) => { + state.canvases = Some(a.canvases.clone()); + ReduceOutcome::Applied + } + StateAction::SessionOpenCanvasesChanged(a) => { + state.open_canvases = Some(a.open_canvases.clone()); + ReduceOutcome::Applied + } StateAction::SessionActiveClientSet(a) => { if let Some(idx) = state .active_clients @@ -1604,6 +1613,39 @@ pub fn apply_action_to_resource_watch( } } +// ─── Canvas Reducer ─────────────────────────────────────────────── + +/// Apply a [`StateAction`] to a [`CanvasState`] in place. +/// +/// `canvas/updated` is a sparse merge — each present field overwrites the +/// corresponding [`CanvasState`] field and an absent field preserves the +/// current value. `canvas/closeRequested` and `canvas/message` are pure +/// signals with no state effect (the host acts on them out of band), +/// mirroring how `terminal/input` is side-effect-only. Actions targeting a +/// different scope short-circuit as [`ReduceOutcome::OutOfScope`]. +pub fn apply_action_to_canvas(state: &mut CanvasState, action: &StateAction) -> ReduceOutcome { + match action { + StateAction::CanvasUpdated(a) => { + if let Some(title) = &a.title { + state.title = Some(title.clone()); + } + if let Some(status) = &a.status { + state.status = Some(status.clone()); + } + if let Some(url) = &a.url { + state.url = Some(url.clone()); + } + if let Some(availability) = &a.availability { + state.availability = availability.clone(); + } + ReduceOutcome::Applied + } + StateAction::CanvasCloseRequested(_) => ReduceOutcome::NoOp, + StateAction::CanvasMessage(_) => ReduceOutcome::NoOp, + _ => ReduceOutcome::OutOfScope, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1636,6 +1678,8 @@ mod tests { lifecycle: SessionLifecycle::Creating, creation_error: None, server_tools: None, + canvases: None, + open_canvases: None, active_clients: Vec::new(), chats: Vec::new(), default_chat: None, @@ -2048,6 +2092,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 5f8ad2df..cbb1ce89 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -56,6 +56,8 @@ fn session_state(title: &str, _resource: &str) -> SessionState { lifecycle: SessionLifecycle::Ready, creation_error: None, server_tools: None, + canvases: None, + open_canvases: None, active_clients: vec![], chats: vec![], default_chat: None, diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index 1cdb02a6..dca82575 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,6 +2,7 @@ "client": "rust", "packageVersion": "0.5.0", "supportedProtocolVersions": [ + "0.6.0", "0.5.1", "0.5.0" ] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 95630c99..fba268de 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -81,6 +81,11 @@ public enum ActionType: String, Codable, Sendable { case terminalCommandExecuted = "terminal/commandExecuted" case terminalCommandFinished = "terminal/commandFinished" case resourceWatchChanged = "resourceWatch/changed" + case sessionCanvasesChanged = "session/canvasesChanged" + case sessionOpenCanvasesChanged = "session/openCanvasesChanged" + case canvasUpdated = "canvas/updated" + case canvasCloseRequested = "canvas/closeRequested" + case canvasMessage = "canvas/message" } // MARK: - Action Infrastructure @@ -971,6 +976,34 @@ public struct SessionServerToolsChangedAction: Codable, Sendable { } } +public struct SessionCanvasesChangedAction: Codable, Sendable { + public var type: ActionType + /// Updated canvas registry (full replacement). + public var canvases: [SessionCanvasDeclaration] + + public init( + type: ActionType, + canvases: [SessionCanvasDeclaration] + ) { + self.type = type + self.canvases = canvases + } +} + +public struct SessionOpenCanvasesChangedAction: Codable, Sendable { + public var type: ActionType + /// Updated open-instance catalogue (full replacement). + public var openCanvases: [OpenCanvasRef] + + public init( + type: ActionType, + openCanvases: [OpenCanvasRef] + ) { + self.type = type + self.openCanvases = openCanvases + } +} + public struct SessionActiveClientSetAction: Codable, Sendable { public var type: ActionType /// The active client to add or update, matched by `clientId`. @@ -1718,6 +1751,56 @@ public struct ResourceWatchChangedAction: Codable, Sendable { } } +public struct CanvasUpdatedAction: Codable, Sendable { + public var type: ActionType + /// New title. Absent preserves the current title. + public var title: String? + /// New provider-defined status. Absent preserves the current status. + public var status: String? + /// New content address. Absent preserves the current url. + public var url: String? + /// New availability. Absent preserves the current availability. + public var availability: CanvasAvailability? + + public init( + type: ActionType, + title: String? = nil, + status: String? = nil, + url: String? = nil, + availability: CanvasAvailability? = nil + ) { + self.type = type + self.title = title + self.status = status + self.url = url + self.availability = availability + } +} + +public struct CanvasCloseRequestedAction: Codable, Sendable { + public var type: ActionType + + public init( + type: ActionType + ) { + self.type = type + } +} + +public struct CanvasMessageAction: Codable, Sendable { + public var type: ActionType + /// Opaque, provider-defined message payload. + public var payload: AnyCodable + + public init( + type: ActionType, + payload: AnyCodable + ) { + self.type = type + self.payload = payload + } +} + // MARK: - Partial Summary Types public struct PartialChatSummary: Codable, Sendable { @@ -1801,6 +1884,8 @@ public enum StateAction: Codable, Sendable { case sessionActivityChanged(SessionActivityChangedAction) case sessionChangesetsChanged(SessionChangesetsChangedAction) case sessionServerToolsChanged(SessionServerToolsChangedAction) + case sessionCanvasesChanged(SessionCanvasesChangedAction) + case sessionOpenCanvasesChanged(SessionOpenCanvasesChangedAction) case sessionActiveClientSet(SessionActiveClientSetAction) case sessionActiveClientRemoved(SessionActiveClientRemovedAction) case sessionInputNeededSet(SessionInputNeededSetAction) @@ -1846,6 +1931,9 @@ public enum StateAction: Codable, Sendable { case terminalCommandExecuted(TerminalCommandExecutedAction) case terminalCommandFinished(TerminalCommandFinishedAction) case resourceWatchChanged(ResourceWatchChangedAction) + case canvasUpdated(CanvasUpdatedAction) + case canvasCloseRequested(CanvasCloseRequestedAction) + case canvasMessage(CanvasMessageAction) /// 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 @@ -1918,6 +2006,10 @@ public enum StateAction: Codable, Sendable { self = .sessionChangesetsChanged(try SessionChangesetsChangedAction(from: decoder)) case "session/serverToolsChanged": self = .sessionServerToolsChanged(try SessionServerToolsChangedAction(from: decoder)) + case "session/canvasesChanged": + self = .sessionCanvasesChanged(try SessionCanvasesChangedAction(from: decoder)) + case "session/openCanvasesChanged": + self = .sessionOpenCanvasesChanged(try SessionOpenCanvasesChangedAction(from: decoder)) case "session/activeClientSet": self = .sessionActiveClientSet(try SessionActiveClientSetAction(from: decoder)) case "session/activeClientRemoved": @@ -2008,6 +2100,12 @@ public enum StateAction: Codable, Sendable { self = .terminalCommandFinished(try TerminalCommandFinishedAction(from: decoder)) case "resourceWatch/changed": self = .resourceWatchChanged(try ResourceWatchChangedAction(from: decoder)) + case "canvas/updated": + self = .canvasUpdated(try CanvasUpdatedAction(from: decoder)) + case "canvas/closeRequested": + self = .canvasCloseRequested(try CanvasCloseRequestedAction(from: decoder)) + case "canvas/message": + self = .canvasMessage(try CanvasMessageAction(from: decoder)) default: self = .unknown(try AnyCodable(from: decoder)) } @@ -2045,6 +2143,8 @@ public enum StateAction: Codable, Sendable { case .sessionActivityChanged(let v): try v.encode(to: encoder) case .sessionChangesetsChanged(let v): try v.encode(to: encoder) case .sessionServerToolsChanged(let v): try v.encode(to: encoder) + case .sessionCanvasesChanged(let v): try v.encode(to: encoder) + case .sessionOpenCanvasesChanged(let v): try v.encode(to: encoder) case .sessionActiveClientSet(let v): try v.encode(to: encoder) case .sessionActiveClientRemoved(let v): try v.encode(to: encoder) case .sessionInputNeededSet(let v): try v.encode(to: encoder) @@ -2090,6 +2190,9 @@ public enum StateAction: Codable, Sendable { case .terminalCommandExecuted(let v): try v.encode(to: encoder) case .terminalCommandFinished(let v): try v.encode(to: encoder) case .resourceWatchChanged(let v): try v.encode(to: encoder) + case .canvasUpdated(let v): try v.encode(to: encoder) + case .canvasCloseRequested(let v): try v.encode(to: encoder) + case .canvasMessage(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 6113d421..1ec36d4e 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -154,11 +154,23 @@ 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 canvases and host client-declared canvas providers — it + /// can render an opaque canvas URL in an isolated surface, and it can answer + /// `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases + /// it declares via {@link SessionActiveClient.canvasProviders}. + /// + /// Hosts SHOULD only populate {@link SessionState.canvases} / + /// {@link SessionState.openCanvases} and only route canvas requests to a + /// client that declared this capability. Clients that omit it see no canvas + /// surface. See {@link /specification/canvas-channel | Canvas Channel}. + public var canvas: [String: AnyCodable]? public init( - mcpApps: [String: AnyCodable]? = nil + mcpApps: [String: AnyCodable]? = nil, + canvas: [String: AnyCodable]? = nil ) { self.mcpApps = mcpApps + self.canvas = canvas } } @@ -1283,6 +1295,166 @@ public struct ChangesetOperationFollowUp: Codable, Sendable { } } +public struct CanvasOpenParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Provider-local canvas id to open. + public var canvasId: String + /// Owning provider id. + public var extensionId: String + /// Caller-minted handle for the new instance. + public var instanceId: String + /// Open input, validated by the provider against its declared schema. + public var input: [String: AnyCodable]? + + public init( + channel: String, + canvasId: String, + extensionId: String, + instanceId: String, + input: [String: AnyCodable]? = nil + ) { + self.channel = channel + self.canvasId = canvasId + self.extensionId = extensionId + self.instanceId = instanceId + self.input = input + } +} + +public struct CanvasOpenResult: Codable, Sendable { + /// Initial content address for the instance (see {@link CanvasState.url}). + public var url: String? + /// Initial title. + public var title: String? + /// Initial provider-defined status. + public var status: String? + + public init( + url: String? = nil, + title: String? = nil, + status: String? = nil + ) { + self.url = url + self.title = title + self.status = status + } +} + +public struct CanvasInvokeActionParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Instance handle the action targets. + public var instanceId: String + /// Provider-local canvas id of the instance. + public var canvasId: String + /// Owning provider id. + public var extensionId: String + /// Declared action name to invoke. + public var actionName: String + /// Action input, validated by the provider against its declared schema. + public var input: [String: AnyCodable]? + + public init( + channel: String, + instanceId: String, + canvasId: String, + extensionId: String, + actionName: String, + input: [String: AnyCodable]? = nil + ) { + self.channel = channel + self.instanceId = instanceId + self.canvasId = canvasId + self.extensionId = extensionId + self.actionName = actionName + self.input = input + } +} + +public struct CanvasInvokeActionResult: Codable, Sendable { + /// Opaque, provider-defined return value. + public var value: AnyCodable? + + public init( + value: AnyCodable? = nil + ) { + self.value = value + } +} + +public struct CanvasCloseParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Instance handle to close. + public var instanceId: String + /// Provider-local canvas id of the instance. + public var canvasId: String + /// Owning provider id. + public var extensionId: String + + public init( + channel: String, + instanceId: String, + canvasId: String, + extensionId: String + ) { + self.channel = channel + self.instanceId = instanceId + self.canvasId = canvasId + self.extensionId = extensionId + } +} + +public struct CanvasReadResourceParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// An `ahp-canvas-content://` content URI to read. + public var uri: String + + public init( + channel: String, + uri: String + ) { + self.channel = channel + self.uri = uri + } +} + +public struct CanvasReadResourceResult: Codable, Sendable { + /// The resolved content parts, wrapped for forward compatibility. + public var contents: [CanvasResourceContent] + + public init( + contents: [CanvasResourceContent] + ) { + self.contents = contents + } +} + +public struct CanvasResourceContent: Codable, Sendable { + /// The content URI this part resolves. + public var uri: String + /// MIME type of the content, when known. + public var mimeType: String? + /// UTF-8 text content, for text payloads. + public var text: String? + /// Base64-encoded content, for binary payloads. + public var blob: String? + + public init( + uri: String, + mimeType: String? = nil, + text: String? = nil, + blob: String? = nil + ) { + self.uri = uri + self.mimeType = mimeType + self.text = text + self.blob = blob + } +} + // MARK: - ReconnectResult Union public enum ReconnectResult: Codable, Sendable { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift index 3b220131..7ee5cc69 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift @@ -40,6 +40,10 @@ public enum AhpErrorCodes { public static let permissionDenied = -32009 /// The target resource already exists and the operation does not allow overwriting public static let alreadyExists = -32010 + /// An optimistic-concurrency precondition failed: a request precondition token no longer matches the resource state + public static let conflict = -32011 + /// A canvas provider request (canvasOpen, canvasInvokeAction, or canvasClose) failed; `data` carries a provider-defined `{ code, message }` + public static let canvasProviderError = -32012 } // MARK: - Error Detail Payloads @@ -82,3 +86,18 @@ public struct UnsupportedProtocolVersionErrorData: Codable, Sendable { self.supportedVersions = supportedVersions } } + +public struct CanvasProviderErrorData: Codable, Sendable { + /// Provider-defined error code identifying the failure. + public var code: String + /// Human-readable error message. + public var message: String + + public init( + code: String, + message: String + ) { + self.code = code + self.message = message + } +} diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 4e278a52..331fa648 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -378,6 +378,24 @@ public enum ResourceChangeType: String, Codable, Sendable { case deleted = "deleted" } +/// Availability of a canvas provider or open instance. +public enum CanvasAvailability: String, Codable, Sendable { + /// The provider is connected and can service requests for this canvas. + case ready = "ready" + /// The provider is temporarily unavailable (for example, a client provider + /// that disconnected). The entry is retained so it can be restored when the + /// provider reconnects; in-flight requests fail until then. + case stale = "stale" +} + +/// Discriminant for {@link CanvasProviderSource}. +public enum CanvasProviderKind: String, Codable, Sendable { + /// The canvas is provided by the host itself. + case server = "server" + /// The canvas is provided by a connected client. + case client = "client" +} + // MARK: - State Types public struct Icon: Codable, Sendable { @@ -1070,6 +1088,25 @@ public struct SessionState: Codable, Sendable { /// chats raise requests and removes them with `session/inputNeededRemoved` /// once the underlying request resolves. public var inputNeeded: [SessionInputRequest]? + /// Aggregated canvas registry currently exposed to the agent — the union of + /// every connected provider (server-side and client-declared). Each entry + /// describes a canvas the agent can open; the host folds + /// {@link SessionActiveClient.canvasProviders | client-declared providers} + /// into this list alongside its own server-side providers. + /// + /// Full-replacement via `session/canvasesChanged`. Populated only when at + /// least one connected client declared {@link ClientCapabilities.canvas}; + /// absent for sessions with no canvas surface. See + /// {@link /specification/canvas-channel | Canvas Channel}. + public var canvases: [SessionCanvasDeclaration]? + /// Lightweight catalogue of currently-open canvas instances. Each entry + /// carries the instance's `ahp-canvas:/` channel URI so a subscriber can + /// subscribe to the full {@link CanvasState} and render it — analogous to how + /// {@link RootState.terminals} catalogues live terminals whose full state + /// lives on each terminal channel. + /// + /// Full-replacement via `session/openCanvasesChanged`. + public var openCanvases: [OpenCanvasRef]? /// Additional provider-specific metadata for this session. /// /// Clients MAY look for well-known keys here to provide enhanced UI. @@ -1095,6 +1132,8 @@ public struct SessionState: Codable, Sendable { case customizations case changesets case inputNeeded + case canvases + case openCanvases case meta = "_meta" } @@ -1116,6 +1155,8 @@ public struct SessionState: Codable, Sendable { customizations: [Customization]? = nil, changesets: [Changeset]? = nil, inputNeeded: [SessionInputRequest]? = nil, + canvases: [SessionCanvasDeclaration]? = nil, + openCanvases: [OpenCanvasRef]? = nil, meta: [String: AnyCodable]? = nil ) { self.provider = provider @@ -1135,6 +1176,8 @@ public struct SessionState: Codable, Sendable { self.customizations = customizations self.changesets = changesets self.inputNeeded = inputNeeded + self.canvases = canvases + self.openCanvases = openCanvases self.meta = meta } } @@ -1153,17 +1196,30 @@ public struct SessionActiveClient: Codable, Sendable { /// plugins in memory and rely on the host to expand them into concrete /// children inside {@link SessionState.customizations}. public var customizations: [ClientPluginCustomization]? + /// Canvas declarations this client contributes as a provider. Published + /// atomically with the rest of the active-client entry via + /// `session/activeClientSet` — exactly like {@link tools} — so there is no + /// separate canvas-provider change action. The host folds these into + /// {@link SessionState.canvases} with + /// {@link SessionCanvasDeclaration.source | `source`} set to + /// `{ kind: 'client', clientId }` and routes + /// `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back + /// to this client. Only meaningful for a client that declared + /// {@link ClientCapabilities.canvas}. + public var canvasProviders: [ClientCanvasDeclaration]? public init( clientId: String, displayName: String? = nil, tools: [ToolDefinition], - customizations: [ClientPluginCustomization]? = nil + customizations: [ClientPluginCustomization]? = nil, + canvasProviders: [ClientCanvasDeclaration]? = nil ) { self.clientId = clientId self.displayName = displayName self.tools = tools self.customizations = customizations + self.canvasProviders = canvasProviders } } @@ -4396,6 +4452,210 @@ public struct ResourceChange: Codable, Sendable { } } +public struct SessionCanvasAction: Codable, Sendable { + /// Action name, unique within the owning `(extensionId, canvasId)`. + public var name: String + /// Human-readable description of what the action does. + public var description: String? + /// JSON Schema for the action's input. Opaque to AHP; mirrors the + /// {@link ToolDefinition.inputSchema} shape. + public var inputSchema: [String: AnyCodable]? + + public init( + name: String, + description: String? = nil, + inputSchema: [String: AnyCodable]? = nil + ) { + self.name = name + self.description = description + self.inputSchema = inputSchema + } +} + +public struct SessionCanvasDeclaration: Codable, Sendable { + /// Owning provider id. Stable across declarations and instances. + public var extensionId: String + /// Human-readable provider name. + public var extensionName: String? + /// Provider-local canvas id. Unique within `extensionId`. + public var canvasId: String + /// Human-readable canvas name. + public var displayName: String + /// Human-readable description of the canvas. + public var description: String + /// JSON Schema for the canvas's open input. Opaque to AHP; mirrors the + /// {@link ToolDefinition.inputSchema} shape. + public var inputSchema: [String: AnyCodable]? + /// Actions this canvas exposes to the agent. + public var actions: [SessionCanvasAction]? + /// Where the declaration came from — for routing and cleanup. + public var source: CanvasProviderSource + + public init( + extensionId: String, + extensionName: String? = nil, + canvasId: String, + displayName: String, + description: String, + inputSchema: [String: AnyCodable]? = nil, + actions: [SessionCanvasAction]? = nil, + source: CanvasProviderSource + ) { + self.extensionId = extensionId + self.extensionName = extensionName + self.canvasId = canvasId + self.displayName = displayName + self.description = description + self.inputSchema = inputSchema + self.actions = actions + self.source = source + } +} + +public struct ClientCanvasDeclaration: Codable, Sendable { + /// Provider-local canvas id, unique within the publishing client. + public var canvasId: String + /// Human-readable canvas name. + public var displayName: String + /// Human-readable description of the canvas. + public var description: String + /// JSON Schema for the canvas's open input. Opaque to AHP. + public var inputSchema: [String: AnyCodable]? + /// Actions this canvas exposes to the agent. + public var actions: [SessionCanvasAction]? + + public init( + canvasId: String, + displayName: String, + description: String, + inputSchema: [String: AnyCodable]? = nil, + actions: [SessionCanvasAction]? = nil + ) { + self.canvasId = canvasId + self.displayName = displayName + self.description = description + self.inputSchema = inputSchema + self.actions = actions + } +} + +public struct OpenCanvasRef: Codable, Sendable { + /// Server-assigned instance handle, unique within the session. + public var instanceId: String + /// The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load + /// the full {@link CanvasState}. + public var channel: String + /// Provider-local canvas id this instance was opened from. + public var canvasId: String + /// Owning provider id. + public var extensionId: String + /// Human-readable provider name. + public var extensionName: String? + /// Current instance title, mirrored from {@link CanvasState.title}. + public var title: String? + /// Whether the instance's provider is currently available. + public var availability: CanvasAvailability + + public init( + instanceId: String, + channel: String, + canvasId: String, + extensionId: String, + extensionName: String? = nil, + title: String? = nil, + availability: CanvasAvailability + ) { + self.instanceId = instanceId + self.channel = channel + self.canvasId = canvasId + self.extensionId = extensionId + self.extensionName = extensionName + self.title = title + self.availability = availability + } +} + +public struct CanvasServerProviderSource: Codable, Sendable { + public var kind: CanvasProviderKind + + public init( + kind: CanvasProviderKind + ) { + self.kind = kind + } +} + +public struct CanvasClientProviderSource: Codable, Sendable { + public var kind: CanvasProviderKind + /// `clientId` of the providing client (matches `initialize`). + public var clientId: String + + public init( + kind: CanvasProviderKind, + clientId: String + ) { + self.kind = kind + self.clientId = clientId + } +} + +public struct CanvasState: Codable, Sendable { + /// Server-assigned instance handle, unique within the session. + public var instanceId: String + /// Provider-local canvas id this instance was opened from. + public var canvasId: String + /// Owning provider id. + public var extensionId: String + /// Human-readable provider name. + public var extensionName: String? + /// Human-readable canvas name. + public var displayName: String? + /// Input the agent supplied when opening the instance. Retained so the + /// instance can be resumed or rebound after a reconnect. + public var input: [String: AnyCodable]? + /// Current instance title. + public var title: String? + /// Provider-defined status string (opaque to AHP). + public var status: String? + /// Renderer-targeted address for the opaque canvas content — either a + /// directly-loadable URL (`https:`, an in-process scheme, `http://localhost`) + /// or a channel-served `ahp-canvas-content://` address the + /// renderer resolves over this channel with `canvasReadResource`. The + /// renderer dispatches on the scheme and enforces its URL policy. See + /// {@link /specification/canvas-channel | Canvas Channel}. + public var url: String? + /// Whether this instance's provider is currently available. + public var availability: CanvasAvailability + /// Which provider owns the callbacks (`canvasOpen` / … ) for this instance. + public var provider: CanvasProviderSource + + public init( + instanceId: String, + canvasId: String, + extensionId: String, + extensionName: String? = nil, + displayName: String? = nil, + input: [String: AnyCodable]? = nil, + title: String? = nil, + status: String? = nil, + url: String? = nil, + availability: CanvasAvailability, + provider: CanvasProviderSource + ) { + self.instanceId = instanceId + self.canvasId = canvasId + self.extensionId = extensionId + self.extensionName = extensionName + self.displayName = displayName + self.input = input + self.title = title + self.status = status + self.url = url + self.availability = availability + self.provider = provider + } +} + // MARK: - Discriminated Unions public struct ChatOriginUser: Codable, Sendable { @@ -5057,6 +5317,39 @@ public enum SessionInputRequest: Codable, Sendable { } } +public enum CanvasProviderSource: Codable, Sendable { + case server(CanvasServerProviderSource) + case client(CanvasClientProviderSource) + /// 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) + let discriminant = try container.decode(String.self, forKey: .discriminant) + switch discriminant { + case "server": + self = .server(try CanvasServerProviderSource(from: decoder)) + case "client": + self = .client(try CanvasClientProviderSource(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .server(let value): try value.encode(to: encoder) + case .client(let value): 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) @@ -5112,7 +5405,7 @@ public enum ToolResultContent: Codable, Sendable { } } -/// The state payload of a snapshot — root, session, chat, terminal, changeset, resource-watch, or annotations state. +/// The state payload of a snapshot — root, session, chat, terminal, changeset, resource-watch, canvas, or annotations state. public enum SnapshotState: Codable, Sendable { case root(RootState) case session(SessionState) @@ -5120,6 +5413,7 @@ public enum SnapshotState: Codable, Sendable { case terminal(TerminalState) case changeset(ChangesetState) case resourceWatch(ResourceWatchState) + case canvas(CanvasState) case annotations(AnnotationsState) public init(from decoder: Decoder) throws { @@ -5137,6 +5431,8 @@ public enum SnapshotState: Codable, Sendable { self = .changeset(changeset) } else if let resourceWatch = try? ResourceWatchState(from: decoder) { self = .resourceWatch(resourceWatch) + } else if let canvas = try? CanvasState(from: decoder) { + self = .canvas(canvas) } else if let annotations = try? AnnotationsState(from: decoder) { self = .annotations(annotations) } else { @@ -5152,6 +5448,7 @@ public enum SnapshotState: Codable, Sendable { case .terminal(let state): try state.encode(to: encoder) case .changeset(let state): try state.encode(to: encoder) case .resourceWatch(let state): try state.encode(to: encoder) + case .canvas(let state): try state.encode(to: encoder) case .annotations(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 d4690313..b170a858 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.5.1" +public let PROTOCOL_VERSION: String = "0.6.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.5.1" /// `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.6.0", "0.5.1", "0.5.0", ] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 0ed159e7..de5aca87 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -599,6 +599,16 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS next.serverTools = a.tools return next + case .sessionCanvasesChanged(let a): + var next = state + next.canvases = a.canvases + return next + + case .sessionOpenCanvasesChanged(let a): + var next = state + next.openCanvases = a.openCanvases + return next + case .sessionActiveClientSet(let a): var next = state if let idx = next.activeClients.firstIndex(where: { $0.clientId == a.activeClient.clientId }) { @@ -1213,3 +1223,36 @@ public func resourceWatchReducer(state: ResourceWatchState, action: StateAction) return state } } + +// MARK: - Canvas Reducer + +/// Pure reducer for canvas state. Handles every canvas action. +/// +/// `canvas/updated` is a sparse merge — a presented field (title, status, url, +/// availability) overwrites the corresponding `CanvasState` field while an +/// absent field preserves the current value. `canvas/closeRequested` and +/// `canvas/message` are pure client→host signals the host acts on out of band, +/// mirroring how `terminal/input` is side-effect-only, so they leave the state +/// unchanged. Unknown action types degrade gracefully so a client speaking an +/// older protocol stays correct if the server adds new `canvas/*` actions in a +/// future version. +public func canvasReducer(state: CanvasState, action: StateAction) -> CanvasState { + switch action { + case .canvasUpdated(let a): + var next = state + if let title = a.title { next.title = title } + if let status = a.status { next.status = status } + if let url = a.url { next.url = url } + if let availability = a.availability { next.availability = availability } + return next + + case .canvasCloseRequested: + return state + + case .canvasMessage: + return state + + default: + return state + } +} diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift index 7c44fb33..f8729f8e 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift @@ -18,6 +18,7 @@ public actor AHPStateMirror { public private(set) var changesets: [String: ChangesetState] = [:] public private(set) var annotations: [String: AnnotationsState] = [:] public private(set) var resourceWatches: [String: ResourceWatchState] = [:] + public private(set) var canvases: [String: CanvasState] = [:] public init() {} @@ -49,6 +50,11 @@ public actor AHPStateMirror { terminals[channel] = terminal return } + if channel.hasPrefix("ahp-canvas:"), var canvas = canvases[channel] { + canvas = canvasReducer(state: canvas, action: action) + canvases[channel] = canvas + return + } if changesets[channel] != nil { // Changesets are also seeded by `applySnapshot` and currently // mutated only when fresh snapshots arrive. @@ -84,6 +90,8 @@ public actor AHPStateMirror { changesets[snapshot.resource] = state case .resourceWatch(let state): resourceWatches[snapshot.resource] = state + case .canvas(let state): + canvases[snapshot.resource] = state case .annotations(let state): annotations[snapshot.resource] = state } @@ -98,5 +106,6 @@ public actor AHPStateMirror { changesets.removeAll() annotations.removeAll() resourceWatches.removeAll() + canvases.removeAll() } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift index 767b7ef2..bfc5ae7a 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift @@ -50,6 +50,7 @@ public actor MultiHostStateMirror { public private(set) var changesets: [HostedResourceKey: ChangesetState] = [:] public private(set) var annotations: [HostedResourceKey: AnnotationsState] = [:] public private(set) var resourceWatches: [HostedResourceKey: ResourceWatchState] = [:] + public private(set) var canvases: [HostedResourceKey: CanvasState] = [:] public init() {} @@ -90,6 +91,11 @@ public actor MultiHostStateMirror { terminals[key] = terminal return } + if channel.hasPrefix("ahp-canvas:"), var canvas = canvases[key] { + canvas = canvasReducer(state: canvas, action: action) + canvases[key] = canvas + return + } if changesets[key] != nil { // Changesets are also seeded by `applySnapshot` and currently // mutated only when fresh snapshots arrive. @@ -128,6 +134,8 @@ public actor MultiHostStateMirror { changesets[key] = state case .resourceWatch(let state): resourceWatches[key] = state + case .canvas(let state): + canvases[key] = state case .annotations(let state): annotations[key] = state } @@ -145,6 +153,7 @@ public actor MultiHostStateMirror { changesets = changesets.filter { $0.key.hostId != host } annotations = annotations.filter { $0.key.hostId != host } resourceWatches = resourceWatches.filter { $0.key.hostId != host } + canvases = canvases.filter { $0.key.hostId != host } } /// Reset every host's state. @@ -156,5 +165,6 @@ public actor MultiHostStateMirror { changesets.removeAll() annotations.removeAll() resourceWatches.removeAll() + canvases.removeAll() } } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift index 4eecdaab..5fd3088d 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift @@ -214,6 +214,10 @@ final class FixtureDrivenReducerTests: XCTestCase { try compareFixture(file: file, fixture: fixture, stateType: AnnotationsState.self) { state in actions.reduce(state) { annotationsReducer(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/CHANGELOG.md b/clients/swift/CHANGELOG.md index 7bf7e226..3be9baa9 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -42,6 +42,16 @@ the tag matches the version pinned in [`VERSION`](VERSION). lifecycle state. - Optional `model` and `tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +- Canvas channel support: the per-instance `CanvasState` plus the + `StateAction.canvasUpdated` / `StateAction.canvasCloseRequested` / + `StateAction.canvasMessage` actions, the `StateAction.sessionCanvasesChanged` + / `StateAction.sessionOpenCanvasesChanged` session actions, and the canvas + discovery types (`SessionCanvasDeclaration`, `ClientCanvasDeclaration`, + `OpenCanvasRef`, `CanvasProviderSource`) on `SessionState.canvases` / + `SessionState.openCanvases`. Adds the `ClientCapabilities.canvas` capability, + the `canvasOpen` / `canvasInvokeAction` / `canvasClose` / `canvasReadResource` + methods, and the `CanvasProviderError` error. The session reducer replaces the + canvas registry/catalogue and the canvas reducer sparse-merges `canvas/updated`. ### Removed diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index 3765f19c..328bb9fc 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,6 +2,7 @@ "client": "swift", "packageVersion": "0.5.0", "supportedProtocolVersions": [ + "0.6.0", "0.5.1", "0.5.0" ] diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 88476600..407c0f15 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -46,6 +46,18 @@ hotfix escape hatch. lifecycle state. - Optional `model` and `tools` fields on `AgentCustomization` for a custom agent's pinned model and tool allowlist. +- Canvas channel support: the per-instance `CanvasState` plus the + `CanvasUpdatedAction` (`canvas/updated`), `CanvasCloseRequestedAction` + (`canvas/closeRequested`), and `CanvasMessageAction` (`canvas/message`) + actions, the `SessionCanvasesChangedAction` (`session/canvasesChanged`) and + `SessionOpenCanvasesChangedAction` (`session/openCanvasesChanged`) session + actions, and the canvas discovery types (`SessionCanvasDeclaration`, + `ClientCanvasDeclaration`, `OpenCanvasRef`, `CanvasProviderSource`) on + `SessionState.canvases` / `SessionState.openCanvases`. Adds the + `ClientCapabilities.canvas` capability, the `canvasOpen` / `canvasInvokeAction` + / `canvasClose` / `canvasReadResource` methods, and the `CanvasProviderError` + error. The session reducer replaces the canvas registry/catalogue and the + `canvasReducer` sparse-merges `canvas/updated`. ### Removed diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index fd3c324d..8090197b 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,6 +2,7 @@ "client": "typescript", "packageVersion": "0.5.0", "supportedProtocolVersions": [ + "0.6.0", "0.5.1", "0.5.0" ] diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 9eda4425..f90119b0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -33,6 +33,7 @@ export default withMermaid(defineConfig({ { text: 'Actions', link: '/guide/actions' }, { text: 'Elicitation', link: '/guide/elicitation' }, { text: 'Terminals', link: '/guide/terminals' }, + { text: 'Canvases', link: '/guide/canvases' }, { text: 'Customizations', link: '/guide/customizations' }, { text: 'MCP Servers', link: '/guide/mcp' }, { text: 'Write-Ahead Reconciliation', link: '/guide/reconciliation' }, @@ -68,6 +69,7 @@ export default withMermaid(defineConfig({ { text: 'Chat Channel', link: '/specification/chat-channel' }, { text: 'Terminal Channel', link: '/specification/terminal-channel' }, { text: 'Resource Watch Channel', link: '/specification/resource-watch-channel' }, + { text: 'Canvas Channel', link: '/specification/canvas-channel' }, { text: 'Telemetry Channel', link: '/specification/telemetry-channel' }, ], }, diff --git a/docs/guide/canvases.md b/docs/guide/canvases.md new file mode 100644 index 00000000..d4b009c6 --- /dev/null +++ b/docs/guide/canvases.md @@ -0,0 +1,51 @@ +# Canvases + +A **canvas** is a rich, interactive UI surface the agent can open next to a session — a document or spreadsheet editor, a diff view, a live web preview, a design surface. Where a chat turn is a stream of messages and tool calls, a canvas is a durable, stateful *view* the user works in directly while the agent keeps assisting. + +Canvases are AHP's native counterpart to [MCP Apps](https://github.com/modelcontextprotocol/ext-apps): where an MCP App is a View embedded in a tool result, a canvas is a first-class, individually subscribable resource with its own channel, discovery surface, and lifecycle — the same "one channel per resource" model used by [terminals](/guide/terminals) and [changesets](/guide/changesets). + +## Two roles a client plays + +A client that declares the `canvas` capability can act in either or both of two roles: + +- **Renderer** — it hosts the isolated surface and displays a canvas by loading its content address. Every capable client is a renderer. +- **Provider** — it *contributes* canvases (a VS Code extension exposing a "diff" canvas, say) and answers the open/invoke/close callbacks for them. A canvas can equally be provided by the host itself. + +The provider owns behaviour; the renderer owns presentation. They are frequently different clients — one client provides a canvas, another renders it — which is exactly why canvas state is synchronised through AHP rather than kept in one process. + +## Discovery + +The session advertises two things once any client declares the capability: + +- **A registry** (`SessionState.canvases`) of every canvas the agent *can* open. The host aggregates it from server-side declarations and the providers each client publishes on `SessionActiveClient.canvasProviders`. +- **An open catalogue** (`SessionState.openCanvases`) of instances that *are* open. Each entry carries the instance's `ahp-canvas:/` channel URI, so a client can render an open canvas by subscribing to that one channel — it never has to subscribe to every instance to know what exists. + +Both lists are published with full-replacement actions (`session/canvasesChanged`, `session/openCanvasesChanged`), matching how the tools catalogue is republished atomically. + +## State-driven rendering + +The defining idea: **a renderer renders from state, not from requests.** Subscribing to a canvas instance yields a `CanvasState` with a `url`, and the renderer resolves that address: + +- a directly-loadable `https:`, in-process, or `http://localhost` address is loaded straight into the surface; or +- an `ahp-canvas-content://` address is resolved over the instance's own channel with `canvasReadResource` — the escape hatch for relayed deployments where the renderer can't reach the host's network directly. + +When the canvas changes, the provider emits a `canvas/updated` action that sparse-merges the new `title` / `status` / `url` / `availability` into state, and every subscriber re-renders. There is no imperative "draw this" message anywhere in the protocol. + +## Interaction + +Two paths carry interaction back to the provider: + +- **Declared actions.** A canvas can declare named actions (with input schemas) the agent may invoke; the host issues a `canvasInvokeAction` request to the provider and returns its opaque result. Opening and closing are the same shape (`canvasOpen`, `canvasClose`). For a host-provided canvas the host resolves these internally and sends no request. +- **The message bridge.** `canvas/message` relays an opaque payload between the rendered View and the provider in either direction — the relay-carried analogue of a `postMessage` bridge. Like `terminal/input`, it is a pure signal with a no-op reducer, so it never accumulates in state. + +Closing follows the same signal-then-request pattern: the user hits ✕, the renderer dispatches `canvas/closeRequested`, and the host runs the close flow — resolving `canvasClose` against the provider and dropping the instance from `openCanvases`. + +## Lifecycle + +1. A client declares `ClientCapabilities.canvas` and publishes any `canvasProviders`. The host folds them into `SessionState.canvases`. +2. The agent opens a canvas. The host mints an instance, sends `canvasOpen` to the provider (or resolves it internally), and adds an `OpenCanvasRef` to `SessionState.openCanvases`. +3. A renderer subscribes to the instance's `ahp-canvas:/` channel, receives the `CanvasState` snapshot, and loads its `url`. +4. The provider pushes `canvas/updated` as the canvas evolves; the agent invokes declared actions; the View and provider exchange `canvas/message` payloads. +5. The user (or the agent) closes the canvas. The host resolves `canvasClose` and republishes `openCanvases` without the instance; subscribers see the channel go away. + +See the [Canvas Channel specification](/specification/canvas-channel) for the exact state shapes, action semantics, command signatures, and error codes. diff --git a/docs/specification/canvas-channel.md b/docs/specification/canvas-channel.md new file mode 100644 index 00000000..a1ab74b6 --- /dev/null +++ b/docs/specification/canvas-channel.md @@ -0,0 +1,208 @@ +# Canvas Channel + +A canvas is a rich, interactive UI surface the agent can open alongside a session — a document editor, a diff view, a live preview, a spreadsheet, a browser pane. Each open canvas is a first-class subscribable resource with its own `ahp-canvas:/` channel, mirroring the "one channel per resource" convention used by terminals, changesets, and resource watches. + +Rendering is **state-driven**: a client renders a canvas by reading its [`CanvasState.url`](#state) and resolving that address per the renderer's URL policy. The host never sends a "render this" request — it publishes state, and the renderer reacts. Interaction flows back to whichever peer *provides* the canvas through a small server ↔ client request family (`canvasOpen` / `canvasInvokeAction` / `canvasClose`) and an opaque `canvas/message` bridge. + +## Capability + +The canvas surface is entirely opt-in. A client advertises it during the handshake: + +```typescript +ClientCapabilities { + canvas?: {} // presence = "I can render canvases and host canvas providers" +} +``` + +A client that declares `canvas` can both **render** an opaque canvas URL in an isolated surface and **provide** canvases — answering `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for the providers it publishes. Hosts SHOULD only populate [`SessionState.canvases`](#discovery) / [`SessionState.openCanvases`](#discovery) and only route canvas requests to a client that declared the capability. Clients that omit it see no canvas surface at all. + +## Discovery + +Two session-state fields expose the canvas surface. Both use full-replacement semantics and are populated only while at least one connected client has declared the capability: + +```typescript +SessionState { + canvases?: SessionCanvasDeclaration[] // what the agent can open + openCanvases?: OpenCanvasRef[] // what is open right now +} +``` + +- **`canvases`** is the aggregated registry of every canvas the agent may open, replaced wholesale via the `session/canvasesChanged` action. It is the union of server-side declarations and the providers each connected client publishes. +- **`openCanvases`** is the lightweight catalogue of currently-open instances, replaced wholesale via `session/openCanvasesChanged`. Each entry carries the instance's channel URI so a subscriber can render it without subscribing to every instance. + +A client contributes providers by publishing them on its active-client entry: + +```typescript +SessionActiveClient { + canvasProviders?: ClientCanvasDeclaration[] +} +``` + +The host folds each `ClientCanvasDeclaration` into `canvases`, filling in the owning `extensionId` and setting `source` to the client variant so it knows where to route later requests. + +### Declarations + +```typescript +// One entry in the aggregated SessionState.canvases registry. +SessionCanvasDeclaration { + extensionId: string // owning provider id + extensionName?: string + canvasId: string // provider-local id, unique within extensionId + displayName: string + description: string + inputSchema?: object // JSON Schema for the open input (opaque to AHP) + actions?: SessionCanvasAction[] + source: CanvasProviderSource // where it came from — for routing and cleanup +} + +// The lighter shape a client publishes; the host derives extensionId + source. +ClientCanvasDeclaration { + canvasId: string + displayName: string + description: string + inputSchema?: object + actions?: SessionCanvasAction[] +} + +// One named action a canvas exposes to the agent. +SessionCanvasAction { + name: string // unique within the owning (extensionId, canvasId) + description?: string + inputSchema?: object +} +``` + +`CanvasProviderSource` is a discriminated union over `CanvasProviderKind` recording who owns the callbacks for a canvas: + +```typescript +CanvasServerProviderSource { kind: 'server' } // the host provides it +CanvasClientProviderSource { kind: 'client', clientId } // a connected client provides it +``` + +### Open-instance catalogue + +Each open instance is summarised by an `OpenCanvasRef`, which carries the `ahp-canvas:/` URI to subscribe to: + +```typescript +OpenCanvasRef { + instanceId: string + channel: URI // ahp-canvas:/ — subscribe for full CanvasState + canvasId: string + extensionId: string + extensionName?: string + title?: string // mirrored from CanvasState.title + availability: CanvasAvailability +} +``` + +## URI + +``` +ahp-canvas:/ +``` + +The id is **server-assigned**. The host allocates a fresh channel URI when it opens an instance and surfaces it on `OpenCanvasRef.channel`; callers MUST treat the URI as opaque. + +## State + +Subscribing to an instance's channel yields the authoritative, mutable per-instance view: + +```typescript +CanvasState { + instanceId: string // server-assigned handle, unique within the session + canvasId: string // provider-local id this instance was opened from + extensionId: string // owning provider id + extensionName?: string + displayName?: string + input?: object // the open input, retained for resume/rebind + title?: string + status?: string // provider-defined, opaque to AHP + url?: string // renderer-targeted content address (see Rendering) + availability: CanvasAvailability + provider: CanvasProviderSource // which provider owns this instance's callbacks +} + +CanvasAvailability = 'ready' | 'stale' // 'stale' = provider currently unavailable +``` + +## Rendering and content + +A renderer displays a canvas by dispatching on the scheme of `CanvasState.url`: + +- A **directly-loadable** address — `https:`, an in-process scheme, or `http://localhost` — is loaded straight into the isolated surface, subject to the renderer's own URL policy. +- An `ahp-canvas-content://` address is **channel-served**: in a relayed deployment the renderer cannot dial the host directly, so it resolves the bytes over the instance's existing channel with [`canvasReadResource`](#commands) instead of loading a network URL. The `` segment names which canvas channel to read from. Sub-resources the loaded document references (stylesheets, images) are fetched the same way. + +The canvas content itself is opaque to AHP — the protocol carries only the address and, when channel-served, the resolved bytes. + +## Actions + +Three actions travel on the per-instance channel. Because the channel is scoped to a single instance by the action envelope, none of them carry an `instanceId`. + +| Action | Client-dispatchable | Reducer effect | +|---|:---:|---| +| `canvas/updated` | No | Sparse-merges `title` / `status` / `url` / `availability` into `CanvasState`. | +| `canvas/closeRequested` | Yes | No-op — signals the host to run the close flow. | +| `canvas/message` | Yes | No-op — opaque View ↔ provider message bridge. | + +`canvas/updated` uses **sparse-merge** semantics: each present field overwrites the corresponding `CanvasState` field, and an absent field preserves the current value. There is no clear-to-absent through this action — a provider that needs to reset a field re-publishes it, and a full reset arrives as a fresh `CanvasState` snapshot on (re)subscribe. + +`canvas/closeRequested` and `canvas/message` are pure signals with no-op reducers, mirroring how `terminal/input` is a side-effect-only client action — they never bloat channel state. `canvas/closeRequested` is a client → host signal (the user hit ✕); the host responds by resolving `canvasClose` against the provider and dropping the instance from `openCanvases`. `canvas/message` is bidirectional: a client dispatches it to carry a View → provider message, and the host emits it to carry a provider → View message. + +## Provider request family + +When the agent opens, drives, or closes a canvas whose provider is a client, the host issues a request to that client. For a server-side provider the host resolves the operation host-internally and emits no request. Following the `resource*` precedent, all three methods are registered in the server → client command map and mirrored in the client → server map for symmetry; a client normally never initiates them, and a receiver SHOULD reject a request whose target is not one of its declared providers. + +```mermaid +sequenceDiagram + autonumber + participant A as Agent + participant H as Host (server) + participant C as Client (provider + renderer) + + Note over C: declares ClientCapabilities.canvas
publishes canvasProviders + C->>H: dispatchAction session/activeClientsChanged (canvasProviders) + H->>C: action session/canvasesChanged (registry) + + A->>H: open canvas "diff" + H->>C: canvasOpen { canvasId, extensionId, instanceId, input } + C-->>H: { url, title } + H->>C: action session/openCanvasesChanged (instance added) + C->>H: subscribe { channel: "ahp-canvas:/abc" } + H-->>C: snapshot { state: CanvasState } + + Note over C: renderer loads CanvasState.url + A->>H: invoke action "format" + H->>C: canvasInvokeAction { instanceId, actionName, input } + C-->>H: { value } + C-->>H: action canvas/updated { status } + + C->>H: action canvas/closeRequested + H->>C: canvasClose { instanceId } + H->>C: action session/openCanvasesChanged (instance removed) +``` + +### Commands + +| Method | Channel | Direction | Purpose | +|---|---|---|---| +| `canvasOpen` | `ahp-session:/` | Client ↔ Server | Open an instance against its provider. Returns the initial `url` / `title` / `status`. | +| `canvasInvokeAction` | `ahp-session:/` | Client ↔ Server | Invoke a declared action on an open instance. Returns an opaque provider value. | +| `canvasClose` | `ahp-session:/` | Client ↔ Server | Close an instance against its provider, as part of the close flow. | +| `canvasReadResource` | `ahp-canvas:/` | Client → Server | Resolve an `ahp-canvas-content:` address over the instance's channel. Returns `contents: CanvasResourceContent[]`. | + +`canvasReadResource` is modeled on MCP's `resources/read`; each `CanvasResourceContent` carries the resolved `uri`, an optional `mimeType`, and exactly one of `text` (UTF-8) or `blob` (base64). + +### Actions + +| Action | Direction | +|---|---| +| `canvas/updated` | host → client (over the standard `action` envelope) | +| `canvas/closeRequested` | client → host | +| `canvas/message` | client → host **or** host → client | + +## Errors + +| Code | Name | When | +|---|---|---| +| `-32012` | `CanvasProviderError` | A `canvasOpen` / `canvasInvokeAction` / `canvasClose` request failed. The `data` field MUST be a `CanvasProviderErrorData` carrying the provider-defined `{ code, message }` — for example `canvas_action_no_handler` when the provider declared the canvas but has no handler for the action, or `canvas_provider_unavailable` when the provider is disconnected. | +| `-32008` | `NotFound` | A `canvasReadResource` content URI does not resolve. | diff --git a/docs/specification/session-channel.md b/docs/specification/session-channel.md index 95cd5d16..55240d19 100644 --- a/docs/specification/session-channel.md +++ b/docs/specification/session-channel.md @@ -86,6 +86,15 @@ Each entry is a [`SessionInputRequest`](/reference/session#sessioninputrequest) Every entry carries the owning `chat` URI plus the identifiers (`request.id`, or `turnId` + `toolCall.toolCallId`) needed to construct the response. A client therefore answers by dispatching the ordinary `chat/*` action **to that chat's channel** — it does **not** need to have subscribed to the chat first. `inputNeeded` is a read/respond convenience surface, not a separate response protocol: the chat channel remains the source of truth and the host removes the aggregate entry once the chat-level request resolves. +### Canvas discovery + +When at least one connected client declares [`ClientCapabilities.canvas`](/specification/canvas-channel#capability), the session surfaces the canvas registry and the open-instance catalogue in its state: + +- `SessionState.canvases` — the aggregated registry of every canvas the agent may open, replaced wholesale via `session/canvasesChanged`. The host builds it from server-side declarations plus the providers each client publishes on [`SessionActiveClient.canvasProviders`](/reference/session#sessionactiveclient). +- `SessionState.openCanvases` — the catalogue of currently-open instances, replaced wholesale via `session/openCanvasesChanged`. Each `OpenCanvasRef` carries an `ahp-canvas:/` channel URI a subscriber renders without subscribing to every instance. + +Both actions are server-only; a client contributes to the registry by publishing `canvasProviders` on its active-client entry, not by dispatching either action. The per-instance channel, its `CanvasState`, and the provider request family are specified on the [Canvas Channel](/specification/canvas-channel) page. + ### Disposal ```jsonc diff --git a/docs/specification/subscriptions.md b/docs/specification/subscriptions.md index 50d1fe70..47fa127a 100644 --- a/docs/specification/subscriptions.md +++ b/docs/specification/subscriptions.md @@ -8,9 +8,10 @@ The channel concept is woven into every wire message. **Every command and every | Direction | Methods | `channel` value | |---|---|---| -| Client → Server commands (channel-scoped) | `subscribe`, `unsubscribe`, `createSession`, `disposeSession`, `createTerminal`, `disposeTerminal`, `fetchTurns`, `completions`, `invokeChangesetOperation` | The target channel's URI (e.g. `ahp-session:/`). | +| Client → Server commands (channel-scoped) | `subscribe`, `unsubscribe`, `createSession`, `disposeSession`, `createTerminal`, `disposeTerminal`, `fetchTurns`, `completions`, `invokeChangesetOperation`, `canvasReadResource` | The target channel's URI (e.g. `ahp-session:/`, or `ahp-canvas:/` for `canvasReadResource`). | | Client → Server commands (connection-level) | `initialize`, `ping`, `reconnect`, `listSessions`, `authenticate`, `resolveSessionConfig`, `sessionConfigCompletions`, `resourceRead`, `resourceWrite`, `resourceList`, `resourceCopy`, `resourceDelete`, `resourceMove`, `resourceResolve`, `resourceMkdir`, `resourceRequest`, `createResourceWatch` | Literal `'ahp-root://'`. | | Server → Client commands (bidirectional `resource*` family) | The same nine `resource*` request methods plus `createResourceWatch` may also be initiated by the server. Used for host-driven per-session filesystem providers and for fetching client-published URIs (e.g. `virtual://my-client/...` plugins). | Literal `'ahp-root://'`. | +| Server → Client commands (canvas provider family) | `canvasOpen`, `canvasInvokeAction`, `canvasClose` — initiated by the host against the client that declared the target canvas provider; mirrored in the client → server map for symmetry. See [Canvas Channel](/specification/canvas-channel). | The owning `ahp-session:/` URI. | | Client → Server `dispatchAction` | The channel the action targets. | | Server → Client `action` | The channel that owns the action envelope. | | Server → Client protocol notifications | `root/sessionAdded`, `root/sessionRemoved`, `root/sessionSummaryChanged`, `auth/required`, `otlp/exportLogs`, `otlp/exportTraces`, `otlp/exportMetrics` | The channel the notification scopes to (the root channel for `root/*`; the channel the auth requirement targets for `auth/required`; the host-defined `ahp-otlp:` channel URI for `otlp/*`). | @@ -29,6 +30,7 @@ The rest of this page details the URI scheme and the lifecycle of a subscription | `ahp-changeset:/` | `ChangesetState` | Per-changeset state. URI is obtained by expanding a `Changeset.uriTemplate` advertised on a session; the id is server-defined. | | `ahp-otlp:` _(authority/path host-defined)_ | _stateless_ | OpenTelemetry signal channels (logs, traces, metrics). Concrete URIs are advertised on `InitializeResult.telemetry`; clients MUST treat them as opaque. See [Telemetry Channel](/specification/telemetry-channel). | | `ahp-resource-watch:/` | `ResourceWatchState` | Per-watch channel returned by `createResourceWatch`. Delivers `resourceWatch/changed` actions for file/directory changes under the watched URI. The id is caller-chosen. | +| `ahp-canvas:/` | `CanvasState` | Per-open-canvas-instance state. URI is server-assigned and surfaced on `OpenCanvasRef.channel`; the id is opaque. See [Canvas Channel](/specification/canvas-channel). | Future channel types (LSP relay, MCP relay, …) introduce their own URI schemes. Clients MUST NOT subscribe to a scheme they do not understand. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 62e065d9..15b0540a 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -405,6 +405,46 @@ "clientId" ] }, + "SessionCanvasesChangedAction": { + "type": "object", + "description": "The aggregated canvas registry for this session changed.\n\nFull-replacement semantics: the `canvases` array replaces\n{@link SessionState.canvases} entirely, mirroring\n`session/serverToolsChanged`. The host republishes the union of every\nconnected provider (server-side and client-declared) whenever it changes.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.SessionCanvasesChanged" + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasDeclaration" + }, + "description": "Updated canvas registry (full replacement)." + } + }, + "required": [ + "type", + "canvases" + ] + }, + "SessionOpenCanvasesChangedAction": { + "type": "object", + "description": "The catalogue of open canvas instances for this session changed.\n\nFull-replacement semantics: the `openCanvases` array replaces\n{@link SessionState.openCanvases} entirely. The host republishes the\ncatalogue as instances open and close.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.SessionOpenCanvasesChanged" + }, + "openCanvases": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenCanvasRef" + }, + "description": "Updated open-instance catalogue (full replacement)." + } + }, + "required": [ + "type", + "openCanvases" + ] + }, "SessionInputNeededSetAction": { "type": "object", "description": "A session-level input request was added or updated.\n\nUpsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the\nhost dispatches this with the full {@link SessionInputRequest} to append a new\nentry to {@link SessionState.inputNeeded} or replace the existing entry with\nthe same `id`.\n\nServer-originated: the host mirrors chat-level requests (elicitations, tool\nconfirmations, client-tool executions) into the session aggregate so clients\nsubscribed only to the session channel can discover them. Clients respond by\ndispatching the ordinary `chat/*` action to the entry's `chat` channel — see\n{@link SessionInputRequest}.", @@ -1842,6 +1882,62 @@ "changes" ] }, + "CanvasUpdatedAction": { + "type": "object", + "description": "The canvas instance's presentation state changed.\n\nSparse-merge semantics: each present field overwrites the corresponding\n{@link CanvasState} field, and an absent field preserves the current value.\nThere is no clear-to-absent via this action — that three-state distinction\ncannot survive JSON transport uniformly across languages, so a provider that\nneeds to reset a field re-publishes it, and a full reset arrives as a fresh\n{@link CanvasState} snapshot on (re)subscribe.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.CanvasUpdated" + }, + "title": { + "type": "string", + "description": "New title. Absent preserves the current title." + }, + "status": { + "type": "string", + "description": "New provider-defined status. Absent preserves the current status." + }, + "url": { + "type": "string", + "description": "New content address. Absent preserves the current url." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "New availability. Absent preserves the current availability." + } + }, + "required": [ + "type" + ] + }, + "CanvasCloseRequestedAction": { + "type": "object", + "description": "The user asked to close this canvas (e.g. hit the ✕ on the surface).\n\nA pure client→host signal with a no-op reducer, mirroring how\n`terminal/input` is a side-effect-only client action. The host runs the\nclose flow in response — resolving `canvasClose` against the provider and\ndropping the instance from {@link SessionState.openCanvases} — rather than\nthe reducer mutating channel state.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.CanvasCloseRequested" + } + }, + "required": [ + "type" + ] + }, + "CanvasMessageAction": { + "type": "object", + "description": "An opaque message relayed between the rendered canvas View and the\ninstance's provider — the relay-carried analogue of a `postMessage` bridge.\n\nBidirectional: a client dispatches it to carry a View→provider message, and\nthe host emits it to carry a provider→View message (routed to the provider\nresolved for the instance, or handled host-internally for a server-side\nprovider). Like `terminal/input` and {@link CanvasCloseRequestedAction} it\nis a pure signal with a no-op reducer, so it never bloats channel state. See\n{@link /specification/canvas-channel | Canvas Channel}.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.CanvasMessage" + }, + "payload": { + "description": "Opaque, provider-defined message payload." + } + }, + "required": [ + "type", + "payload" + ] + }, "ChatToolCallConfirmedAction": { "oneOf": [ {}, @@ -2349,6 +2445,9 @@ { "$ref": "#/$defs/ResourceWatchState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AnnotationsState" }, @@ -2692,6 +2791,20 @@ }, "description": "Outstanding input the session is blocked on, aggregated across every chat\nso a client can discover and answer it from the session channel alone,\nwithout subscribing to individual chats.\n\nEach entry is self-sufficient: it carries the owning chat's URI plus every\nidentifier the client needs to respond. A client answers by dispatching the\nordinary `chat/*` action to that chat's channel — see\n{@link SessionInputRequest} for the per-variant response path. A present,\nnon-empty list implies {@link SessionStatus.InputNeeded} on\n{@link SessionSummary.status}.\n\nHost-managed: the host upserts entries with `session/inputNeededSet` as\nchats raise requests and removes them with `session/inputNeededRemoved`\nonce the underlying request resolves." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasDeclaration" + }, + "description": "Aggregated canvas registry currently exposed to the agent — the union of\nevery connected provider (server-side and client-declared). Each entry\ndescribes a canvas the agent can open; the host folds\n{@link SessionActiveClient.canvasProviders | client-declared providers}\ninto this list alongside its own server-side providers.\n\nFull-replacement via `session/canvasesChanged`. Populated only when at\nleast one connected client declared {@link ClientCapabilities.canvas};\nabsent for sessions with no canvas surface. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "openCanvases": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenCanvasRef" + }, + "description": "Lightweight catalogue of currently-open canvas instances. Each entry\ncarries the instance's `ahp-canvas:/` channel URI so a subscriber can\nsubscribe to the full {@link CanvasState} and render it — analogous to how\n{@link RootState.terminals} catalogues live terminals whose full state\nlives on each terminal channel.\n\nFull-replacement via `session/openCanvasesChanged`." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2732,6 +2845,13 @@ "$ref": "#/$defs/ClientPluginCustomization" }, "description": "Plugin customizations this client contributes to the session.\n\nClients publish in [Open Plugins](https://open-plugins.com/) format\n— i.e. always container-shaped plugins. They MAY synthesize virtual\nplugins in memory and rely on the host to expand them into concrete\nchildren inside {@link SessionState.customizations}." + }, + "canvasProviders": { + "type": "array", + "items": { + "$ref": "#/$defs/ClientCanvasDeclaration" + }, + "description": "Canvas declarations this client contributes as a provider. Published\natomically with the rest of the active-client entry via\n`session/activeClientSet` — exactly like {@link tools} — so there is no\nseparate canvas-provider change action. The host folds these into\n{@link SessionState.canvases} with\n{@link SessionCanvasDeclaration.source | `source`} set to\n`{ kind: 'client', clientId }` and routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back\nto this client. Only meaningful for a client that declared\n{@link ClientCapabilities.canvas}." } }, "required": [ @@ -2739,6 +2859,182 @@ "tools" ] }, + "CanvasServerProviderSource": { + "type": "object", + "description": "A canvas provided by the host. Carries no `clientId` — server-side provider\nrequests are resolved host-internally rather than routed to a peer.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Server" + } + }, + "required": [ + "kind" + ] + }, + "CanvasClientProviderSource": { + "type": "object", + "description": "A canvas provided by a connected client. The host routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas\nto the identified client.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Client" + }, + "clientId": { + "type": "string", + "description": "`clientId` of the providing client (matches `initialize`)." + } + }, + "required": [ + "kind", + "clientId" + ] + }, + "SessionCanvasAction": { + "type": "object", + "description": "One named action a canvas exposes to the agent, mirroring the shape of a\n{@link ToolDefinition} entry. Unique within its owning\n`(extensionId, canvasId)`.", + "properties": { + "name": { + "type": "string", + "description": "Action name, unique within the owning `(extensionId, canvasId)`." + }, + "description": { + "type": "string", + "description": "Human-readable description of what the action does." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the action's input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + } + }, + "required": [ + "name" + ] + }, + "SessionCanvasDeclaration": { + "type": "object", + "description": "One entry in the aggregated {@link SessionState.canvases} registry — a canvas\nthe agent can open, contributed by a server-side or client-declared provider.", + "properties": { + "extensionId": { + "type": "string", + "description": "Owning provider id. Stable across declarations and instances." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id. Unique within `extensionId`." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + }, + "source": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Where the declaration came from — for routing and cleanup." + } + }, + "required": [ + "extensionId", + "canvasId", + "displayName", + "description", + "source" + ] + }, + "ClientCanvasDeclaration": { + "type": "object", + "description": "The lighter declaration shape a client publishes on\n{@link SessionActiveClient.canvasProviders}. The host derives the\n`extensionId` and {@link SessionCanvasDeclaration.source | `source`} when\nfolding it into {@link SessionState.canvases}.", + "properties": { + "canvasId": { + "type": "string", + "description": "Provider-local canvas id, unique within the publishing client." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + } + }, + "required": [ + "canvasId", + "displayName", + "description" + ] + }, + "OpenCanvasRef": { + "type": "object", + "description": "A lightweight catalogue entry for one open canvas instance, surfaced on\n{@link SessionState.openCanvases}. The authoritative, mutable per-instance\nstate lives on the instance's own {@link CanvasState} channel; this entry\nexists so a subscriber can discover the channel URI and render it without\nsubscribing to every instance.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "channel": { + "$ref": "#/$defs/URI", + "description": "The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load\nthe full {@link CanvasState}." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "title": { + "type": "string", + "description": "Current instance title, mirrored from {@link CanvasState.title}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether the instance's provider is currently available." + } + }, + "required": [ + "instanceId", + "channel", + "canvasId", + "extensionId", + "availability" + ] + }, "SessionInputRequestBase": { "type": "object", "description": "Fields common to every {@link SessionInputRequest} variant.", @@ -6249,6 +6545,64 @@ "type" ] }, + "CanvasState": { + "type": "object", + "description": "Full state for a single open canvas instance, delivered when a client\nsubscribes to the instance's `ahp-canvas:/` channel.\n\nOne channel exists per open instance — the same \"one channel per resource\"\nconvention used by terminals, changesets, and resource watches. The\nlightweight catalogue entry that advertises this channel is\n{@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the\nauthoritative, mutable per-instance view a renderer reads.\n\nRendering is state-driven: a client renders the canvas by reading\n{@link url} and resolving it per the renderer's URL policy — directly for a\nreachable address, or over this channel via `canvasReadResource` for an\n`ahp-canvas-content:` address. It never receives a \"render this\" request.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input the agent supplied when opening the instance. Retained so the\ninstance can be resumed or rebound after a reconnect." + }, + "title": { + "type": "string", + "description": "Current instance title." + }, + "status": { + "type": "string", + "description": "Provider-defined status string (opaque to AHP)." + }, + "url": { + "type": "string", + "description": "Renderer-targeted address for the opaque canvas content — either a\ndirectly-loadable URL (`https:`, an in-process scheme, `http://localhost`)\nor a channel-served `ahp-canvas-content://` address the\nrenderer resolves over this channel with `canvasReadResource`. The\nrenderer dispatches on the scheme and enforces its URL policy. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether this instance's provider is currently available." + }, + "provider": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Which provider owns the callbacks (`canvasOpen` / … ) for this instance." + } + }, + "required": [ + "instanceId", + "canvasId", + "extensionId", + "availability", + "provider" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -6283,6 +6637,18 @@ ], "description": "A primitive JSON value: a string, number, boolean, or `null`." }, + "CanvasProviderSource": { + "oneOf": [ + {}, + { + "$ref": "#/$defs/CanvasServerProviderSource" + }, + { + "$ref": "#/$defs/CanvasClientProviderSource" + } + ], + "description": "Where a canvas declaration came from — used for request routing and for\ncleaning the entry up when its provider disconnects. Modeled as a\ndiscriminated union so the `clientId` is only present (and required) for the\nclient variant." + }, "SessionInputRequest": { "oneOf": [ {}, @@ -6866,6 +7232,21 @@ }, { "$ref": "#/$defs/ResourceWatchChangedAction" + }, + { + "$ref": "#/$defs/SessionCanvasesChangedAction" + }, + { + "$ref": "#/$defs/SessionOpenCanvasesChangedAction" + }, + { + "$ref": "#/$defs/CanvasUpdatedAction" + }, + { + "$ref": "#/$defs/CanvasCloseRequestedAction" + }, + { + "$ref": "#/$defs/CanvasMessageAction" } ] } diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 0136d547..f7f5e57c 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -93,6 +93,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." + }, + "canvas": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render canvases and host client-declared canvas providers — it\ncan render an opaque canvas URL in an isolated surface, and it can answer\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases\nit declares via {@link SessionActiveClient.canvasProviders}.\n\nHosts SHOULD only populate {@link SessionState.canvases} /\n{@link SessionState.openCanvases} and only route canvas requests to a\nclient that declared this capability. Clients that omit it see no canvas\nsurface. See {@link /specification/canvas-channel | Canvas Channel}." } } }, @@ -1276,6 +1281,191 @@ "channel" ] }, + "CanvasOpenParams": { + "type": "object", + "description": "Opens a canvas instance against its provider.\n\nSent by the host to the client that declared the target canvas via\n{@link SessionActiveClient.canvasProviders} (a client that also declared\n{@link ClientCapabilities.canvas}). For a server-side provider the host\nresolves the open host-internally and emits no request. The provider returns\nthe initial render target and presentation fields, which the host folds into\nthe new instance's {@link CanvasState}.\n\nMirrors the `resource*` precedent: registered in `ServerCommandMap` and\nmirrored in `CommandMap` for symmetry. A client normally never initiates it —\nthe host is not a canvas provider — and a receiver SHOULD reject a request\nwhose target is not one of its declared providers.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning session channel URI (`ahp-session:/`)." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id to open." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "instanceId": { + "type": "string", + "description": "Caller-minted handle for the new instance." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Open input, validated by the provider against its declared schema." + } + }, + "required": [ + "channel", + "canvasId", + "extensionId", + "instanceId" + ] + }, + "CanvasOpenResult": { + "type": "object", + "description": "Result of the `canvasOpen` command.", + "properties": { + "url": { + "type": "string", + "description": "Initial content address for the instance (see {@link CanvasState.url})." + }, + "title": { + "type": "string", + "description": "Initial title." + }, + "status": { + "type": "string", + "description": "Initial provider-defined status." + } + } + }, + "CanvasInvokeActionParams": { + "type": "object", + "description": "Invokes one of a canvas's declared actions against its provider.\n\nSent by the host to the providing client (or resolved host-internally for a\nserver-side provider) when the agent invokes a\n{@link SessionCanvasAction | declared action} on an open instance. The\nprovider returns an opaque, provider-defined value. Registered symmetrically\nwith the rest of the provider family (see {@link CanvasOpenParams}).", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning session channel URI (`ahp-session:/`)." + }, + "instanceId": { + "type": "string", + "description": "Instance handle the action targets." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id of the instance." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "actionName": { + "type": "string", + "description": "Declared action name to invoke." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Action input, validated by the provider against its declared schema." + } + }, + "required": [ + "channel", + "instanceId", + "canvasId", + "extensionId", + "actionName" + ] + }, + "CanvasInvokeActionResult": { + "type": "object", + "description": "Result of the `canvasInvokeAction` command.", + "properties": { + "value": { + "description": "Opaque, provider-defined return value." + } + } + }, + "CanvasCloseParams": { + "type": "object", + "description": "Closes a canvas instance against its provider.\n\nSent by the host to the providing client (or resolved host-internally for a\nserver-side provider) as part of the close flow — typically after a client\ndispatches `canvas/closeRequested`. The host then drops the instance from\n{@link SessionState.openCanvases}. Registered symmetrically with the rest of\nthe provider family (see {@link CanvasOpenParams}).", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning session channel URI (`ahp-session:/`)." + }, + "instanceId": { + "type": "string", + "description": "Instance handle to close." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id of the instance." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + } + }, + "required": [ + "channel", + "instanceId", + "canvasId", + "extensionId" + ] + }, + "CanvasReadResourceParams": { + "type": "object", + "description": "Reads channel-served canvas content by `ahp-canvas-content:` URI.\n\nA client → host request, modeled on MCP's `resources/read`. When a canvas's\n{@link CanvasState.url} (or a sub-resource the rendered document references)\nis an `ahp-canvas-content://` address, the renderer cannot\ndial the host directly — for example in a relayed deployment behind a broker\n— so it resolves the bytes over the instance's existing `ahp-canvas:/`\nchannel with this request instead of loading a network URL. The ``\nsegment of the URI identifies which canvas channel to read from. See\n{@link /specification/canvas-channel | Canvas Channel}.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning canvas channel URI (`ahp-canvas:/`)." + }, + "uri": { + "type": "string", + "description": "An `ahp-canvas-content://` content URI to read." + } + }, + "required": [ + "channel", + "uri" + ] + }, + "CanvasReadResourceResult": { + "type": "object", + "description": "Result of the `canvasReadResource` command.", + "properties": { + "contents": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasResourceContent" + }, + "description": "The resolved content parts, wrapped for forward compatibility." + } + }, + "required": [ + "contents" + ] + }, + "CanvasResourceContent": { + "type": "object", + "description": "One resolved piece of channel-served canvas content.\n\nCarries exactly one of {@link text} (text payloads) or {@link blob}\n(base64-encoded binary payloads).", + "properties": { + "uri": { + "type": "string", + "description": "The content URI this part resolves." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the content, when known." + }, + "text": { + "type": "string", + "description": "UTF-8 text content, for text payloads." + }, + "blob": { + "type": "string", + "description": "Base64-encoded content, for binary payloads." + } + }, + "required": [ + "uri" + ] + }, "Icon": { "type": "object", "description": "An optionally-sized icon that can be displayed in a user interface.", @@ -1694,6 +1884,9 @@ { "$ref": "#/$defs/ResourceWatchState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AnnotationsState" }, @@ -2037,6 +2230,20 @@ }, "description": "Outstanding input the session is blocked on, aggregated across every chat\nso a client can discover and answer it from the session channel alone,\nwithout subscribing to individual chats.\n\nEach entry is self-sufficient: it carries the owning chat's URI plus every\nidentifier the client needs to respond. A client answers by dispatching the\nordinary `chat/*` action to that chat's channel — see\n{@link SessionInputRequest} for the per-variant response path. A present,\nnon-empty list implies {@link SessionStatus.InputNeeded} on\n{@link SessionSummary.status}.\n\nHost-managed: the host upserts entries with `session/inputNeededSet` as\nchats raise requests and removes them with `session/inputNeededRemoved`\nonce the underlying request resolves." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasDeclaration" + }, + "description": "Aggregated canvas registry currently exposed to the agent — the union of\nevery connected provider (server-side and client-declared). Each entry\ndescribes a canvas the agent can open; the host folds\n{@link SessionActiveClient.canvasProviders | client-declared providers}\ninto this list alongside its own server-side providers.\n\nFull-replacement via `session/canvasesChanged`. Populated only when at\nleast one connected client declared {@link ClientCapabilities.canvas};\nabsent for sessions with no canvas surface. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "openCanvases": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenCanvasRef" + }, + "description": "Lightweight catalogue of currently-open canvas instances. Each entry\ncarries the instance's `ahp-canvas:/` channel URI so a subscriber can\nsubscribe to the full {@link CanvasState} and render it — analogous to how\n{@link RootState.terminals} catalogues live terminals whose full state\nlives on each terminal channel.\n\nFull-replacement via `session/openCanvasesChanged`." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -2077,6 +2284,13 @@ "$ref": "#/$defs/ClientPluginCustomization" }, "description": "Plugin customizations this client contributes to the session.\n\nClients publish in [Open Plugins](https://open-plugins.com/) format\n— i.e. always container-shaped plugins. They MAY synthesize virtual\nplugins in memory and rely on the host to expand them into concrete\nchildren inside {@link SessionState.customizations}." + }, + "canvasProviders": { + "type": "array", + "items": { + "$ref": "#/$defs/ClientCanvasDeclaration" + }, + "description": "Canvas declarations this client contributes as a provider. Published\natomically with the rest of the active-client entry via\n`session/activeClientSet` — exactly like {@link tools} — so there is no\nseparate canvas-provider change action. The host folds these into\n{@link SessionState.canvases} with\n{@link SessionCanvasDeclaration.source | `source`} set to\n`{ kind: 'client', clientId }` and routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back\nto this client. Only meaningful for a client that declared\n{@link ClientCapabilities.canvas}." } }, "required": [ @@ -2084,6 +2298,182 @@ "tools" ] }, + "CanvasServerProviderSource": { + "type": "object", + "description": "A canvas provided by the host. Carries no `clientId` — server-side provider\nrequests are resolved host-internally rather than routed to a peer.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Server" + } + }, + "required": [ + "kind" + ] + }, + "CanvasClientProviderSource": { + "type": "object", + "description": "A canvas provided by a connected client. The host routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas\nto the identified client.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Client" + }, + "clientId": { + "type": "string", + "description": "`clientId` of the providing client (matches `initialize`)." + } + }, + "required": [ + "kind", + "clientId" + ] + }, + "SessionCanvasAction": { + "type": "object", + "description": "One named action a canvas exposes to the agent, mirroring the shape of a\n{@link ToolDefinition} entry. Unique within its owning\n`(extensionId, canvasId)`.", + "properties": { + "name": { + "type": "string", + "description": "Action name, unique within the owning `(extensionId, canvasId)`." + }, + "description": { + "type": "string", + "description": "Human-readable description of what the action does." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the action's input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + } + }, + "required": [ + "name" + ] + }, + "SessionCanvasDeclaration": { + "type": "object", + "description": "One entry in the aggregated {@link SessionState.canvases} registry — a canvas\nthe agent can open, contributed by a server-side or client-declared provider.", + "properties": { + "extensionId": { + "type": "string", + "description": "Owning provider id. Stable across declarations and instances." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id. Unique within `extensionId`." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + }, + "source": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Where the declaration came from — for routing and cleanup." + } + }, + "required": [ + "extensionId", + "canvasId", + "displayName", + "description", + "source" + ] + }, + "ClientCanvasDeclaration": { + "type": "object", + "description": "The lighter declaration shape a client publishes on\n{@link SessionActiveClient.canvasProviders}. The host derives the\n`extensionId` and {@link SessionCanvasDeclaration.source | `source`} when\nfolding it into {@link SessionState.canvases}.", + "properties": { + "canvasId": { + "type": "string", + "description": "Provider-local canvas id, unique within the publishing client." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + } + }, + "required": [ + "canvasId", + "displayName", + "description" + ] + }, + "OpenCanvasRef": { + "type": "object", + "description": "A lightweight catalogue entry for one open canvas instance, surfaced on\n{@link SessionState.openCanvases}. The authoritative, mutable per-instance\nstate lives on the instance's own {@link CanvasState} channel; this entry\nexists so a subscriber can discover the channel URI and render it without\nsubscribing to every instance.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "channel": { + "$ref": "#/$defs/URI", + "description": "The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load\nthe full {@link CanvasState}." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "title": { + "type": "string", + "description": "Current instance title, mirrored from {@link CanvasState.title}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether the instance's provider is currently available." + } + }, + "required": [ + "instanceId", + "channel", + "canvasId", + "extensionId", + "availability" + ] + }, "SessionInputRequestBase": { "type": "object", "description": "Fields common to every {@link SessionInputRequest} variant.", @@ -5594,6 +5984,64 @@ "type" ] }, + "CanvasState": { + "type": "object", + "description": "Full state for a single open canvas instance, delivered when a client\nsubscribes to the instance's `ahp-canvas:/` channel.\n\nOne channel exists per open instance — the same \"one channel per resource\"\nconvention used by terminals, changesets, and resource watches. The\nlightweight catalogue entry that advertises this channel is\n{@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the\nauthoritative, mutable per-instance view a renderer reads.\n\nRendering is state-driven: a client renders the canvas by reading\n{@link url} and resolving it per the renderer's URL policy — directly for a\nreachable address, or over this channel via `canvasReadResource` for an\n`ahp-canvas-content:` address. It never receives a \"render this\" request.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input the agent supplied when opening the instance. Retained so the\ninstance can be resumed or rebound after a reconnect." + }, + "title": { + "type": "string", + "description": "Current instance title." + }, + "status": { + "type": "string", + "description": "Provider-defined status string (opaque to AHP)." + }, + "url": { + "type": "string", + "description": "Renderer-targeted address for the opaque canvas content — either a\ndirectly-loadable URL (`https:`, an in-process scheme, `http://localhost`)\nor a channel-served `ahp-canvas-content://` address the\nrenderer resolves over this channel with `canvasReadResource`. The\nrenderer dispatches on the scheme and enforces its URL policy. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether this instance's provider is currently available." + }, + "provider": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Which provider owns the callbacks (`canvasOpen` / … ) for this instance." + } + }, + "required": [ + "instanceId", + "canvasId", + "extensionId", + "availability", + "provider" + ] + }, "ActionOrigin": { "type": "object", "description": "Identifies the client that originally dispatched an action.", @@ -5994,6 +6442,46 @@ "clientId" ] }, + "SessionCanvasesChangedAction": { + "type": "object", + "description": "The aggregated canvas registry for this session changed.\n\nFull-replacement semantics: the `canvases` array replaces\n{@link SessionState.canvases} entirely, mirroring\n`session/serverToolsChanged`. The host republishes the union of every\nconnected provider (server-side and client-declared) whenever it changes.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.SessionCanvasesChanged" + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasDeclaration" + }, + "description": "Updated canvas registry (full replacement)." + } + }, + "required": [ + "type", + "canvases" + ] + }, + "SessionOpenCanvasesChangedAction": { + "type": "object", + "description": "The catalogue of open canvas instances for this session changed.\n\nFull-replacement semantics: the `openCanvases` array replaces\n{@link SessionState.openCanvases} entirely. The host republishes the\ncatalogue as instances open and close.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.SessionOpenCanvasesChanged" + }, + "openCanvases": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenCanvasRef" + }, + "description": "Updated open-instance catalogue (full replacement)." + } + }, + "required": [ + "type", + "openCanvases" + ] + }, "SessionInputNeededSetAction": { "type": "object", "description": "A session-level input request was added or updated.\n\nUpsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the\nhost dispatches this with the full {@link SessionInputRequest} to append a new\nentry to {@link SessionState.inputNeeded} or replace the existing entry with\nthe same `id`.\n\nServer-originated: the host mirrors chat-level requests (elicitations, tool\nconfirmations, client-tool executions) into the session aggregate so clients\nsubscribed only to the session channel can discover them. Clients respond by\ndispatching the ordinary `chat/*` action to the entry's `chat` channel — see\n{@link SessionInputRequest}.", @@ -7430,6 +7918,62 @@ "type", "changes" ] + }, + "CanvasUpdatedAction": { + "type": "object", + "description": "The canvas instance's presentation state changed.\n\nSparse-merge semantics: each present field overwrites the corresponding\n{@link CanvasState} field, and an absent field preserves the current value.\nThere is no clear-to-absent via this action — that three-state distinction\ncannot survive JSON transport uniformly across languages, so a provider that\nneeds to reset a field re-publishes it, and a full reset arrives as a fresh\n{@link CanvasState} snapshot on (re)subscribe.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.CanvasUpdated" + }, + "title": { + "type": "string", + "description": "New title. Absent preserves the current title." + }, + "status": { + "type": "string", + "description": "New provider-defined status. Absent preserves the current status." + }, + "url": { + "type": "string", + "description": "New content address. Absent preserves the current url." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "New availability. Absent preserves the current availability." + } + }, + "required": [ + "type" + ] + }, + "CanvasCloseRequestedAction": { + "type": "object", + "description": "The user asked to close this canvas (e.g. hit the ✕ on the surface).\n\nA pure client→host signal with a no-op reducer, mirroring how\n`terminal/input` is a side-effect-only client action. The host runs the\nclose flow in response — resolving `canvasClose` against the provider and\ndropping the instance from {@link SessionState.openCanvases} — rather than\nthe reducer mutating channel state.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.CanvasCloseRequested" + } + }, + "required": [ + "type" + ] + }, + "CanvasMessageAction": { + "type": "object", + "description": "An opaque message relayed between the rendered canvas View and the\ninstance's provider — the relay-carried analogue of a `postMessage` bridge.\n\nBidirectional: a client dispatches it to carry a View→provider message, and\nthe host emits it to carry a provider→View message (routed to the provider\nresolved for the instance, or handled host-internally for a server-side\nprovider). Like `terminal/input` and {@link CanvasCloseRequestedAction} it\nis a pure signal with a no-op reducer, so it never bloats channel state. See\n{@link /specification/canvas-channel | Canvas Channel}.", + "properties": { + "type": { + "$ref": "#/$defs/ActionType.CanvasMessage" + }, + "payload": { + "description": "Opaque, provider-defined message payload." + } + }, + "required": [ + "type", + "payload" + ] } } } diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 08d3863e..85151dc7 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -74,6 +74,24 @@ "supportedVersions" ] }, + "CanvasProviderErrorData": { + "type": "object", + "description": "Details carried in the `data` field of a `CanvasProviderError` (-32012)\nerror.\n\nThe `code` is a provider-defined string (opaque to AHP) identifying the\nfailure — observed values include `canvas_action_no_handler` and\n`canvas_provider_unavailable`. `message` is a human-readable description.", + "properties": { + "code": { + "type": "string", + "description": "Provider-defined error code identifying the failure." + }, + "message": { + "type": "string", + "description": "Human-readable error message." + } + }, + "required": [ + "code", + "message" + ] + }, "AhpErrorDetailsMap": { "type": "object", "description": "Maps each AHP error code that carries structured `data` to the type of\nthat data.\n\nError codes not present in this map either have no `data` payload or\ncarry an unspecified payload that callers SHOULD treat as `unknown`.", @@ -86,12 +104,16 @@ }, "[AhpErrorCodes.UnsupportedProtocolVersion]": { "$ref": "#/$defs/UnsupportedProtocolVersionErrorData" + }, + "[AhpErrorCodes.CanvasProviderError]": { + "$ref": "#/$defs/CanvasProviderErrorData" } }, "required": [ "[AhpErrorCodes.AuthRequired]", "[AhpErrorCodes.PermissionDenied]", - "[AhpErrorCodes.UnsupportedProtocolVersion]" + "[AhpErrorCodes.UnsupportedProtocolVersion]", + "[AhpErrorCodes.CanvasProviderError]" ] }, "Icon": { @@ -512,6 +534,9 @@ { "$ref": "#/$defs/ResourceWatchState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AnnotationsState" }, @@ -855,6 +880,20 @@ }, "description": "Outstanding input the session is blocked on, aggregated across every chat\nso a client can discover and answer it from the session channel alone,\nwithout subscribing to individual chats.\n\nEach entry is self-sufficient: it carries the owning chat's URI plus every\nidentifier the client needs to respond. A client answers by dispatching the\nordinary `chat/*` action to that chat's channel — see\n{@link SessionInputRequest} for the per-variant response path. A present,\nnon-empty list implies {@link SessionStatus.InputNeeded} on\n{@link SessionSummary.status}.\n\nHost-managed: the host upserts entries with `session/inputNeededSet` as\nchats raise requests and removes them with `session/inputNeededRemoved`\nonce the underlying request resolves." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasDeclaration" + }, + "description": "Aggregated canvas registry currently exposed to the agent — the union of\nevery connected provider (server-side and client-declared). Each entry\ndescribes a canvas the agent can open; the host folds\n{@link SessionActiveClient.canvasProviders | client-declared providers}\ninto this list alongside its own server-side providers.\n\nFull-replacement via `session/canvasesChanged`. Populated only when at\nleast one connected client declared {@link ClientCapabilities.canvas};\nabsent for sessions with no canvas surface. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "openCanvases": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenCanvasRef" + }, + "description": "Lightweight catalogue of currently-open canvas instances. Each entry\ncarries the instance's `ahp-canvas:/` channel URI so a subscriber can\nsubscribe to the full {@link CanvasState} and render it — analogous to how\n{@link RootState.terminals} catalogues live terminals whose full state\nlives on each terminal channel.\n\nFull-replacement via `session/openCanvasesChanged`." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -895,6 +934,13 @@ "$ref": "#/$defs/ClientPluginCustomization" }, "description": "Plugin customizations this client contributes to the session.\n\nClients publish in [Open Plugins](https://open-plugins.com/) format\n— i.e. always container-shaped plugins. They MAY synthesize virtual\nplugins in memory and rely on the host to expand them into concrete\nchildren inside {@link SessionState.customizations}." + }, + "canvasProviders": { + "type": "array", + "items": { + "$ref": "#/$defs/ClientCanvasDeclaration" + }, + "description": "Canvas declarations this client contributes as a provider. Published\natomically with the rest of the active-client entry via\n`session/activeClientSet` — exactly like {@link tools} — so there is no\nseparate canvas-provider change action. The host folds these into\n{@link SessionState.canvases} with\n{@link SessionCanvasDeclaration.source | `source`} set to\n`{ kind: 'client', clientId }` and routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back\nto this client. Only meaningful for a client that declared\n{@link ClientCapabilities.canvas}." } }, "required": [ @@ -902,6 +948,182 @@ "tools" ] }, + "CanvasServerProviderSource": { + "type": "object", + "description": "A canvas provided by the host. Carries no `clientId` — server-side provider\nrequests are resolved host-internally rather than routed to a peer.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Server" + } + }, + "required": [ + "kind" + ] + }, + "CanvasClientProviderSource": { + "type": "object", + "description": "A canvas provided by a connected client. The host routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas\nto the identified client.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Client" + }, + "clientId": { + "type": "string", + "description": "`clientId` of the providing client (matches `initialize`)." + } + }, + "required": [ + "kind", + "clientId" + ] + }, + "SessionCanvasAction": { + "type": "object", + "description": "One named action a canvas exposes to the agent, mirroring the shape of a\n{@link ToolDefinition} entry. Unique within its owning\n`(extensionId, canvasId)`.", + "properties": { + "name": { + "type": "string", + "description": "Action name, unique within the owning `(extensionId, canvasId)`." + }, + "description": { + "type": "string", + "description": "Human-readable description of what the action does." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the action's input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + } + }, + "required": [ + "name" + ] + }, + "SessionCanvasDeclaration": { + "type": "object", + "description": "One entry in the aggregated {@link SessionState.canvases} registry — a canvas\nthe agent can open, contributed by a server-side or client-declared provider.", + "properties": { + "extensionId": { + "type": "string", + "description": "Owning provider id. Stable across declarations and instances." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id. Unique within `extensionId`." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + }, + "source": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Where the declaration came from — for routing and cleanup." + } + }, + "required": [ + "extensionId", + "canvasId", + "displayName", + "description", + "source" + ] + }, + "ClientCanvasDeclaration": { + "type": "object", + "description": "The lighter declaration shape a client publishes on\n{@link SessionActiveClient.canvasProviders}. The host derives the\n`extensionId` and {@link SessionCanvasDeclaration.source | `source`} when\nfolding it into {@link SessionState.canvases}.", + "properties": { + "canvasId": { + "type": "string", + "description": "Provider-local canvas id, unique within the publishing client." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + } + }, + "required": [ + "canvasId", + "displayName", + "description" + ] + }, + "OpenCanvasRef": { + "type": "object", + "description": "A lightweight catalogue entry for one open canvas instance, surfaced on\n{@link SessionState.openCanvases}. The authoritative, mutable per-instance\nstate lives on the instance's own {@link CanvasState} channel; this entry\nexists so a subscriber can discover the channel URI and render it without\nsubscribing to every instance.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "channel": { + "$ref": "#/$defs/URI", + "description": "The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load\nthe full {@link CanvasState}." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "title": { + "type": "string", + "description": "Current instance title, mirrored from {@link CanvasState.title}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether the instance's provider is currently available." + } + }, + "required": [ + "instanceId", + "channel", + "canvasId", + "extensionId", + "availability" + ] + }, "SessionInputRequestBase": { "type": "object", "description": "Fields common to every {@link SessionInputRequest} variant.", @@ -4412,6 +4634,64 @@ "type" ] }, + "CanvasState": { + "type": "object", + "description": "Full state for a single open canvas instance, delivered when a client\nsubscribes to the instance's `ahp-canvas:/` channel.\n\nOne channel exists per open instance — the same \"one channel per resource\"\nconvention used by terminals, changesets, and resource watches. The\nlightweight catalogue entry that advertises this channel is\n{@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the\nauthoritative, mutable per-instance view a renderer reads.\n\nRendering is state-driven: a client renders the canvas by reading\n{@link url} and resolving it per the renderer's URL policy — directly for a\nreachable address, or over this channel via `canvasReadResource` for an\n`ahp-canvas-content:` address. It never receives a \"render this\" request.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input the agent supplied when opening the instance. Retained so the\ninstance can be resumed or rebound after a reconnect." + }, + "title": { + "type": "string", + "description": "Current instance title." + }, + "status": { + "type": "string", + "description": "Provider-defined status string (opaque to AHP)." + }, + "url": { + "type": "string", + "description": "Renderer-targeted address for the opaque canvas content — either a\ndirectly-loadable URL (`https:`, an in-process scheme, `http://localhost`)\nor a channel-served `ahp-canvas-content://` address the\nrenderer resolves over this channel with `canvasReadResource`. The\nrenderer dispatches on the scheme and enforces its URL policy. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether this instance's provider is currently available." + }, + "provider": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Which provider owns the callbacks (`canvasOpen` / … ) for this instance." + } + }, + "required": [ + "instanceId", + "canvasId", + "extensionId", + "availability", + "provider" + ] + }, "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.", @@ -4500,6 +4780,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." + }, + "canvas": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render canvases and host client-declared canvas providers — it\ncan render an opaque canvas URL in an isolated surface, and it can answer\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases\nit declares via {@link SessionActiveClient.canvasProviders}.\n\nHosts SHOULD only populate {@link SessionState.canvases} /\n{@link SessionState.openCanvases} and only route canvas requests to a\nclient that declared this capability. Clients that omit it see no canvas\nsurface. See {@link /specification/canvas-channel | Canvas Channel}." } } }, @@ -5682,6 +5967,191 @@ "required": [ "channel" ] + }, + "CanvasOpenParams": { + "type": "object", + "description": "Opens a canvas instance against its provider.\n\nSent by the host to the client that declared the target canvas via\n{@link SessionActiveClient.canvasProviders} (a client that also declared\n{@link ClientCapabilities.canvas}). For a server-side provider the host\nresolves the open host-internally and emits no request. The provider returns\nthe initial render target and presentation fields, which the host folds into\nthe new instance's {@link CanvasState}.\n\nMirrors the `resource*` precedent: registered in `ServerCommandMap` and\nmirrored in `CommandMap` for symmetry. A client normally never initiates it —\nthe host is not a canvas provider — and a receiver SHOULD reject a request\nwhose target is not one of its declared providers.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning session channel URI (`ahp-session:/`)." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id to open." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "instanceId": { + "type": "string", + "description": "Caller-minted handle for the new instance." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Open input, validated by the provider against its declared schema." + } + }, + "required": [ + "channel", + "canvasId", + "extensionId", + "instanceId" + ] + }, + "CanvasOpenResult": { + "type": "object", + "description": "Result of the `canvasOpen` command.", + "properties": { + "url": { + "type": "string", + "description": "Initial content address for the instance (see {@link CanvasState.url})." + }, + "title": { + "type": "string", + "description": "Initial title." + }, + "status": { + "type": "string", + "description": "Initial provider-defined status." + } + } + }, + "CanvasInvokeActionParams": { + "type": "object", + "description": "Invokes one of a canvas's declared actions against its provider.\n\nSent by the host to the providing client (or resolved host-internally for a\nserver-side provider) when the agent invokes a\n{@link SessionCanvasAction | declared action} on an open instance. The\nprovider returns an opaque, provider-defined value. Registered symmetrically\nwith the rest of the provider family (see {@link CanvasOpenParams}).", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning session channel URI (`ahp-session:/`)." + }, + "instanceId": { + "type": "string", + "description": "Instance handle the action targets." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id of the instance." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "actionName": { + "type": "string", + "description": "Declared action name to invoke." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Action input, validated by the provider against its declared schema." + } + }, + "required": [ + "channel", + "instanceId", + "canvasId", + "extensionId", + "actionName" + ] + }, + "CanvasInvokeActionResult": { + "type": "object", + "description": "Result of the `canvasInvokeAction` command.", + "properties": { + "value": { + "description": "Opaque, provider-defined return value." + } + } + }, + "CanvasCloseParams": { + "type": "object", + "description": "Closes a canvas instance against its provider.\n\nSent by the host to the providing client (or resolved host-internally for a\nserver-side provider) as part of the close flow — typically after a client\ndispatches `canvas/closeRequested`. The host then drops the instance from\n{@link SessionState.openCanvases}. Registered symmetrically with the rest of\nthe provider family (see {@link CanvasOpenParams}).", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning session channel URI (`ahp-session:/`)." + }, + "instanceId": { + "type": "string", + "description": "Instance handle to close." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id of the instance." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + } + }, + "required": [ + "channel", + "instanceId", + "canvasId", + "extensionId" + ] + }, + "CanvasReadResourceParams": { + "type": "object", + "description": "Reads channel-served canvas content by `ahp-canvas-content:` URI.\n\nA client → host request, modeled on MCP's `resources/read`. When a canvas's\n{@link CanvasState.url} (or a sub-resource the rendered document references)\nis an `ahp-canvas-content://` address, the renderer cannot\ndial the host directly — for example in a relayed deployment behind a broker\n— so it resolves the bytes over the instance's existing `ahp-canvas:/`\nchannel with this request instead of loading a network URL. The ``\nsegment of the URI identifies which canvas channel to read from. See\n{@link /specification/canvas-channel | Canvas Channel}.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The owning canvas channel URI (`ahp-canvas:/`)." + }, + "uri": { + "type": "string", + "description": "An `ahp-canvas-content://` content URI to read." + } + }, + "required": [ + "channel", + "uri" + ] + }, + "CanvasReadResourceResult": { + "type": "object", + "description": "Result of the `canvasReadResource` command.", + "properties": { + "contents": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasResourceContent" + }, + "description": "The resolved content parts, wrapped for forward compatibility." + } + }, + "required": [ + "contents" + ] + }, + "CanvasResourceContent": { + "type": "object", + "description": "One resolved piece of channel-served canvas content.\n\nCarries exactly one of {@link text} (text payloads) or {@link blob}\n(base64-encoded binary payloads).", + "properties": { + "uri": { + "type": "string", + "description": "The content URI this part resolves." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the content, when known." + }, + "text": { + "type": "string", + "description": "UTF-8 text content, for text payloads." + }, + "blob": { + "type": "string", + "description": "Base64-encoded content, for binary payloads." + } + }, + "required": [ + "uri" + ] } } } diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index fe8b92f3..bddc77e1 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -672,6 +672,9 @@ { "$ref": "#/$defs/ResourceWatchState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AnnotationsState" }, @@ -1015,6 +1018,20 @@ }, "description": "Outstanding input the session is blocked on, aggregated across every chat\nso a client can discover and answer it from the session channel alone,\nwithout subscribing to individual chats.\n\nEach entry is self-sufficient: it carries the owning chat's URI plus every\nidentifier the client needs to respond. A client answers by dispatching the\nordinary `chat/*` action to that chat's channel — see\n{@link SessionInputRequest} for the per-variant response path. A present,\nnon-empty list implies {@link SessionStatus.InputNeeded} on\n{@link SessionSummary.status}.\n\nHost-managed: the host upserts entries with `session/inputNeededSet` as\nchats raise requests and removes them with `session/inputNeededRemoved`\nonce the underlying request resolves." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasDeclaration" + }, + "description": "Aggregated canvas registry currently exposed to the agent — the union of\nevery connected provider (server-side and client-declared). Each entry\ndescribes a canvas the agent can open; the host folds\n{@link SessionActiveClient.canvasProviders | client-declared providers}\ninto this list alongside its own server-side providers.\n\nFull-replacement via `session/canvasesChanged`. Populated only when at\nleast one connected client declared {@link ClientCapabilities.canvas};\nabsent for sessions with no canvas surface. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "openCanvases": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenCanvasRef" + }, + "description": "Lightweight catalogue of currently-open canvas instances. Each entry\ncarries the instance's `ahp-canvas:/` channel URI so a subscriber can\nsubscribe to the full {@link CanvasState} and render it — analogous to how\n{@link RootState.terminals} catalogues live terminals whose full state\nlives on each terminal channel.\n\nFull-replacement via `session/openCanvasesChanged`." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -1055,6 +1072,13 @@ "$ref": "#/$defs/ClientPluginCustomization" }, "description": "Plugin customizations this client contributes to the session.\n\nClients publish in [Open Plugins](https://open-plugins.com/) format\n— i.e. always container-shaped plugins. They MAY synthesize virtual\nplugins in memory and rely on the host to expand them into concrete\nchildren inside {@link SessionState.customizations}." + }, + "canvasProviders": { + "type": "array", + "items": { + "$ref": "#/$defs/ClientCanvasDeclaration" + }, + "description": "Canvas declarations this client contributes as a provider. Published\natomically with the rest of the active-client entry via\n`session/activeClientSet` — exactly like {@link tools} — so there is no\nseparate canvas-provider change action. The host folds these into\n{@link SessionState.canvases} with\n{@link SessionCanvasDeclaration.source | `source`} set to\n`{ kind: 'client', clientId }` and routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back\nto this client. Only meaningful for a client that declared\n{@link ClientCapabilities.canvas}." } }, "required": [ @@ -1062,6 +1086,182 @@ "tools" ] }, + "CanvasServerProviderSource": { + "type": "object", + "description": "A canvas provided by the host. Carries no `clientId` — server-side provider\nrequests are resolved host-internally rather than routed to a peer.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Server" + } + }, + "required": [ + "kind" + ] + }, + "CanvasClientProviderSource": { + "type": "object", + "description": "A canvas provided by a connected client. The host routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas\nto the identified client.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Client" + }, + "clientId": { + "type": "string", + "description": "`clientId` of the providing client (matches `initialize`)." + } + }, + "required": [ + "kind", + "clientId" + ] + }, + "SessionCanvasAction": { + "type": "object", + "description": "One named action a canvas exposes to the agent, mirroring the shape of a\n{@link ToolDefinition} entry. Unique within its owning\n`(extensionId, canvasId)`.", + "properties": { + "name": { + "type": "string", + "description": "Action name, unique within the owning `(extensionId, canvasId)`." + }, + "description": { + "type": "string", + "description": "Human-readable description of what the action does." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the action's input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + } + }, + "required": [ + "name" + ] + }, + "SessionCanvasDeclaration": { + "type": "object", + "description": "One entry in the aggregated {@link SessionState.canvases} registry — a canvas\nthe agent can open, contributed by a server-side or client-declared provider.", + "properties": { + "extensionId": { + "type": "string", + "description": "Owning provider id. Stable across declarations and instances." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id. Unique within `extensionId`." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + }, + "source": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Where the declaration came from — for routing and cleanup." + } + }, + "required": [ + "extensionId", + "canvasId", + "displayName", + "description", + "source" + ] + }, + "ClientCanvasDeclaration": { + "type": "object", + "description": "The lighter declaration shape a client publishes on\n{@link SessionActiveClient.canvasProviders}. The host derives the\n`extensionId` and {@link SessionCanvasDeclaration.source | `source`} when\nfolding it into {@link SessionState.canvases}.", + "properties": { + "canvasId": { + "type": "string", + "description": "Provider-local canvas id, unique within the publishing client." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + } + }, + "required": [ + "canvasId", + "displayName", + "description" + ] + }, + "OpenCanvasRef": { + "type": "object", + "description": "A lightweight catalogue entry for one open canvas instance, surfaced on\n{@link SessionState.openCanvases}. The authoritative, mutable per-instance\nstate lives on the instance's own {@link CanvasState} channel; this entry\nexists so a subscriber can discover the channel URI and render it without\nsubscribing to every instance.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "channel": { + "$ref": "#/$defs/URI", + "description": "The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load\nthe full {@link CanvasState}." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "title": { + "type": "string", + "description": "Current instance title, mirrored from {@link CanvasState.title}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether the instance's provider is currently available." + } + }, + "required": [ + "instanceId", + "channel", + "canvasId", + "extensionId", + "availability" + ] + }, "SessionInputRequestBase": { "type": "object", "description": "Fields common to every {@link SessionInputRequest} variant.", @@ -4571,6 +4771,64 @@ "uri", "type" ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single open canvas instance, delivered when a client\nsubscribes to the instance's `ahp-canvas:/` channel.\n\nOne channel exists per open instance — the same \"one channel per resource\"\nconvention used by terminals, changesets, and resource watches. The\nlightweight catalogue entry that advertises this channel is\n{@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the\nauthoritative, mutable per-instance view a renderer reads.\n\nRendering is state-driven: a client renders the canvas by reading\n{@link url} and resolving it per the renderer's URL policy — directly for a\nreachable address, or over this channel via `canvasReadResource` for an\n`ahp-canvas-content:` address. It never receives a \"render this\" request.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input the agent supplied when opening the instance. Retained so the\ninstance can be resumed or rebound after a reconnect." + }, + "title": { + "type": "string", + "description": "Current instance title." + }, + "status": { + "type": "string", + "description": "Provider-defined status string (opaque to AHP)." + }, + "url": { + "type": "string", + "description": "Renderer-targeted address for the opaque canvas content — either a\ndirectly-loadable URL (`https:`, an in-process scheme, `http://localhost`)\nor a channel-served `ahp-canvas-content://` address the\nrenderer resolves over this channel with `canvasReadResource`. The\nrenderer dispatches on the scheme and enforces its URL policy. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether this instance's provider is currently available." + }, + "provider": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Which provider owns the callbacks (`canvasOpen` / … ) for this instance." + } + }, + "required": [ + "instanceId", + "canvasId", + "extensionId", + "availability", + "provider" + ] } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index f58baac1..2bcbce01 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -423,6 +423,9 @@ { "$ref": "#/$defs/ResourceWatchState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AnnotationsState" }, @@ -766,6 +769,20 @@ }, "description": "Outstanding input the session is blocked on, aggregated across every chat\nso a client can discover and answer it from the session channel alone,\nwithout subscribing to individual chats.\n\nEach entry is self-sufficient: it carries the owning chat's URI plus every\nidentifier the client needs to respond. A client answers by dispatching the\nordinary `chat/*` action to that chat's channel — see\n{@link SessionInputRequest} for the per-variant response path. A present,\nnon-empty list implies {@link SessionStatus.InputNeeded} on\n{@link SessionSummary.status}.\n\nHost-managed: the host upserts entries with `session/inputNeededSet` as\nchats raise requests and removes them with `session/inputNeededRemoved`\nonce the underlying request resolves." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasDeclaration" + }, + "description": "Aggregated canvas registry currently exposed to the agent — the union of\nevery connected provider (server-side and client-declared). Each entry\ndescribes a canvas the agent can open; the host folds\n{@link SessionActiveClient.canvasProviders | client-declared providers}\ninto this list alongside its own server-side providers.\n\nFull-replacement via `session/canvasesChanged`. Populated only when at\nleast one connected client declared {@link ClientCapabilities.canvas};\nabsent for sessions with no canvas surface. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "openCanvases": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenCanvasRef" + }, + "description": "Lightweight catalogue of currently-open canvas instances. Each entry\ncarries the instance's `ahp-canvas:/` channel URI so a subscriber can\nsubscribe to the full {@link CanvasState} and render it — analogous to how\n{@link RootState.terminals} catalogues live terminals whose full state\nlives on each terminal channel.\n\nFull-replacement via `session/openCanvasesChanged`." + }, "_meta": { "type": "object", "additionalProperties": {}, @@ -806,6 +823,13 @@ "$ref": "#/$defs/ClientPluginCustomization" }, "description": "Plugin customizations this client contributes to the session.\n\nClients publish in [Open Plugins](https://open-plugins.com/) format\n— i.e. always container-shaped plugins. They MAY synthesize virtual\nplugins in memory and rely on the host to expand them into concrete\nchildren inside {@link SessionState.customizations}." + }, + "canvasProviders": { + "type": "array", + "items": { + "$ref": "#/$defs/ClientCanvasDeclaration" + }, + "description": "Canvas declarations this client contributes as a provider. Published\natomically with the rest of the active-client entry via\n`session/activeClientSet` — exactly like {@link tools} — so there is no\nseparate canvas-provider change action. The host folds these into\n{@link SessionState.canvases} with\n{@link SessionCanvasDeclaration.source | `source`} set to\n`{ kind: 'client', clientId }` and routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back\nto this client. Only meaningful for a client that declared\n{@link ClientCapabilities.canvas}." } }, "required": [ @@ -813,6 +837,182 @@ "tools" ] }, + "CanvasServerProviderSource": { + "type": "object", + "description": "A canvas provided by the host. Carries no `clientId` — server-side provider\nrequests are resolved host-internally rather than routed to a peer.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Server" + } + }, + "required": [ + "kind" + ] + }, + "CanvasClientProviderSource": { + "type": "object", + "description": "A canvas provided by a connected client. The host routes\n`canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas\nto the identified client.", + "properties": { + "kind": { + "$ref": "#/$defs/CanvasProviderKind.Client" + }, + "clientId": { + "type": "string", + "description": "`clientId` of the providing client (matches `initialize`)." + } + }, + "required": [ + "kind", + "clientId" + ] + }, + "SessionCanvasAction": { + "type": "object", + "description": "One named action a canvas exposes to the agent, mirroring the shape of a\n{@link ToolDefinition} entry. Unique within its owning\n`(extensionId, canvasId)`.", + "properties": { + "name": { + "type": "string", + "description": "Action name, unique within the owning `(extensionId, canvasId)`." + }, + "description": { + "type": "string", + "description": "Human-readable description of what the action does." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the action's input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + } + }, + "required": [ + "name" + ] + }, + "SessionCanvasDeclaration": { + "type": "object", + "description": "One entry in the aggregated {@link SessionState.canvases} registry — a canvas\nthe agent can open, contributed by a server-side or client-declared provider.", + "properties": { + "extensionId": { + "type": "string", + "description": "Owning provider id. Stable across declarations and instances." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id. Unique within `extensionId`." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP; mirrors the\n{@link ToolDefinition.inputSchema} shape." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + }, + "source": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Where the declaration came from — for routing and cleanup." + } + }, + "required": [ + "extensionId", + "canvasId", + "displayName", + "description", + "source" + ] + }, + "ClientCanvasDeclaration": { + "type": "object", + "description": "The lighter declaration shape a client publishes on\n{@link SessionActiveClient.canvasProviders}. The host derives the\n`extensionId` and {@link SessionCanvasDeclaration.source | `source`} when\nfolding it into {@link SessionState.canvases}.", + "properties": { + "canvasId": { + "type": "string", + "description": "Provider-local canvas id, unique within the publishing client." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "description": { + "type": "string", + "description": "Human-readable description of the canvas." + }, + "inputSchema": { + "type": "object", + "additionalProperties": {}, + "description": "JSON Schema for the canvas's open input. Opaque to AHP." + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionCanvasAction" + }, + "description": "Actions this canvas exposes to the agent." + } + }, + "required": [ + "canvasId", + "displayName", + "description" + ] + }, + "OpenCanvasRef": { + "type": "object", + "description": "A lightweight catalogue entry for one open canvas instance, surfaced on\n{@link SessionState.openCanvases}. The authoritative, mutable per-instance\nstate lives on the instance's own {@link CanvasState} channel; this entry\nexists so a subscriber can discover the channel URI and render it without\nsubscribing to every instance.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "channel": { + "$ref": "#/$defs/URI", + "description": "The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load\nthe full {@link CanvasState}." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "title": { + "type": "string", + "description": "Current instance title, mirrored from {@link CanvasState.title}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether the instance's provider is currently available." + } + }, + "required": [ + "instanceId", + "channel", + "canvasId", + "extensionId", + "availability" + ] + }, "SessionInputRequestBase": { "type": "object", "description": "Fields common to every {@link SessionInputRequest} variant.", @@ -4323,6 +4523,64 @@ "type" ] }, + "CanvasState": { + "type": "object", + "description": "Full state for a single open canvas instance, delivered when a client\nsubscribes to the instance's `ahp-canvas:/` channel.\n\nOne channel exists per open instance — the same \"one channel per resource\"\nconvention used by terminals, changesets, and resource watches. The\nlightweight catalogue entry that advertises this channel is\n{@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the\nauthoritative, mutable per-instance view a renderer reads.\n\nRendering is state-driven: a client renders the canvas by reading\n{@link url} and resolving it per the renderer's URL policy — directly for a\nreachable address, or over this channel via `canvasReadResource` for an\n`ahp-canvas-content:` address. It never receives a \"render this\" request.", + "properties": { + "instanceId": { + "type": "string", + "description": "Server-assigned instance handle, unique within the session." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas id this instance was opened from." + }, + "extensionId": { + "type": "string", + "description": "Owning provider id." + }, + "extensionName": { + "type": "string", + "description": "Human-readable provider name." + }, + "displayName": { + "type": "string", + "description": "Human-readable canvas name." + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input the agent supplied when opening the instance. Retained so the\ninstance can be resumed or rebound after a reconnect." + }, + "title": { + "type": "string", + "description": "Current instance title." + }, + "status": { + "type": "string", + "description": "Provider-defined status string (opaque to AHP)." + }, + "url": { + "type": "string", + "description": "Renderer-targeted address for the opaque canvas content — either a\ndirectly-loadable URL (`https:`, an in-process scheme, `http://localhost`)\nor a channel-served `ahp-canvas-content://` address the\nrenderer resolves over this channel with `canvasReadResource`. The\nrenderer dispatches on the scheme and enforces its URL policy. See\n{@link /specification/canvas-channel | Canvas Channel}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailability", + "description": "Whether this instance's provider is currently available." + }, + "provider": { + "$ref": "#/$defs/CanvasProviderSource", + "description": "Which provider owns the callbacks (`canvasOpen` / … ) for this instance." + } + }, + "required": [ + "instanceId", + "canvasId", + "extensionId", + "availability", + "provider" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -4357,6 +4615,18 @@ ], "description": "A primitive JSON value: a string, number, boolean, or `null`." }, + "CanvasProviderSource": { + "oneOf": [ + {}, + { + "$ref": "#/$defs/CanvasServerProviderSource" + }, + { + "$ref": "#/$defs/CanvasClientProviderSource" + } + ], + "description": "Where a canvas declaration came from — used for request routing and for\ncleaning the entry up when its provider disconnects. Modeled as a\ndiscriminated union so the `clientId` is only present (and required) for the\nclient variant." + }, "SessionInputRequest": { "oneOf": [ {}, diff --git a/scripts/find-protocol-sources.ts b/scripts/find-protocol-sources.ts index 862088bd..c1b3d8e7 100644 --- a/scripts/find-protocol-sources.ts +++ b/scripts/find-protocol-sources.ts @@ -24,6 +24,7 @@ export const PROTOCOL_SOURCE_DIRS: readonly string[] = [ 'channels-annotations', 'channels-otlp', 'channels-resource-watch', + 'channels-canvas', ]; /** diff --git a/scripts/generate-action-origin.ts b/scripts/generate-action-origin.ts index 0b66aa73..395767f7 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'; +type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'canvas'; interface ActionInfo { /** The interface name (e.g. 'RootAgentsChangedAction') */ @@ -155,6 +155,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { : category === 'Changeset Actions' ? 'changeset' : category === 'Annotations Actions' ? 'annotations' : category === 'Resource Watch Actions' ? 'resourceWatch' + : category === 'Canvas Actions' ? 'canvas' : 'session'; const isClientDispatchable = hasJsDocTag(node as any, 'clientDispatchable'); @@ -207,6 +208,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const changesetActions = actions.filter(a => a.scope === 'changeset'); const annotationsActions = actions.filter(a => a.scope === 'annotations'); const resourceWatchActions = actions.filter(a => a.scope === 'resourceWatch'); + 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); @@ -221,6 +223,8 @@ export function generateActionOrigin(project: Project, outDir: string): void { const serverAnnotationsActions = annotationsActions.filter(a => !a.isClientDispatchable); const clientResourceWatchActions = resourceWatchActions.filter(a => a.isClientDispatchable); const serverResourceWatchActions = resourceWatchActions.filter(a => !a.isClientDispatchable); + const clientCanvasActions = canvasActions.filter(a => a.isClientDispatchable); + const serverCanvasActions = canvasActions.filter(a => !a.isClientDispatchable); const lines: string[] = [GENERATED_HEADER]; @@ -459,6 +463,46 @@ export function generateActionOrigin(project: Project, outDir: string): void { lines.push(``); + // CanvasAction + lines.push(`/** Union of all canvas-scoped actions. */`); + lines.push(`export type CanvasAction =`); + if (canvasActions.length === 0) { + lines.push(` never`); + } else { + for (let i = 0; i < canvasActions.length; i++) { + lines.push(` | ${canvasActions[i].name}`); + } + } + lines.push(`;`); + lines.push(``); + + // ClientCanvasAction + lines.push(`/** Union of canvas actions that clients may dispatch. */`); + lines.push(`export type ClientCanvasAction =`); + if (clientCanvasActions.length === 0) { + lines.push(` never`); + } else { + for (let i = 0; i < clientCanvasActions.length; i++) { + lines.push(` | ${clientCanvasActions[i].name}`); + } + } + lines.push(`;`); + lines.push(``); + + // ServerCanvasAction + lines.push(`/** Union of canvas actions that only the server may produce. */`); + lines.push(`export type ServerCanvasAction =`); + if (serverCanvasActions.length === 0) { + lines.push(` never`); + } else { + for (let i = 0; i < serverCanvasActions.length; i++) { + lines.push(` | ${serverCanvasActions[i].name}`); + } + } + lines.push(`;`); + lines.push(``); + + // IS_CLIENT_DISPATCHABLE map lines.push(`// ─── Client-Dispatchable Map ─────────────────────────────────────────────────`); lines.push(``); diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index e3e9627e..d38d9243 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -171,6 +171,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | CanvasState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState' ) { @@ -649,6 +650,7 @@ const STATE_ENUMS = [ 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', + 'CanvasAvailability', 'CanvasProviderKind', ]; const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [ @@ -768,6 +770,13 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'TelemetryCapabilities' }, { name: 'ResourceWatchState' }, { name: 'ResourceChange' }, + { name: 'SessionCanvasAction' }, + { name: 'SessionCanvasDeclaration' }, + { name: 'ClientCanvasDeclaration' }, + { name: 'OpenCanvasRef' }, + { name: 'CanvasServerProviderSource' }, + { name: 'CanvasClientProviderSource' }, + { name: 'CanvasState' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -978,6 +987,17 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { unknown: true, }; +const CANVAS_PROVIDER_SOURCE_UNION: UnionConfig = { + name: 'CanvasProviderSource', + discriminantField: 'kind', + doc: 'CanvasProviderSource identifies where a canvas declaration came from — used for request routing and cleanup when its provider disconnects.', + variants: [ + { variantName: 'Server', innerType: 'CanvasServerProviderSource', wireValue: 'server' }, + { variantName: 'Client', innerType: 'CanvasClientProviderSource', wireValue: 'client' }, + ], + unknown: true, +}; + function generateChatOriginGo(): string { return `// ChatOrigin describes how a chat came into existence. type ChatOrigin struct { @@ -1063,10 +1083,10 @@ func (o ChatOrigin) MarshalJSON() ([]byte, error) { function generateSnapshotState(): string { return `// SnapshotState is the state payload of a snapshot — root, session, -// chat, terminal, changeset, resource-watch, or annotations state. The active -// variant is chosen by which pointer field is non-nil; UnmarshalJSON probes -// for required fields in the canonical order -// (session → chat → terminal → changeset → resourceWatch → annotations → root). +// chat, terminal, changeset, resource-watch, canvas, or annotations state. The +// active variant is chosen by which pointer field is non-nil; UnmarshalJSON +// probes for required fields in the canonical order +// (session → chat → terminal → changeset → resourceWatch → canvas → annotations → root). type SnapshotState struct { \tRoot *RootState \`json:"-"\` \tSession *SessionState \`json:"-"\` @@ -1074,6 +1094,7 @@ type SnapshotState struct { \tTerminal *TerminalState \`json:"-"\` \tChangeset *ChangesetState \`json:"-"\` \tResourceWatch *ResourceWatchState \`json:"-"\` +\tCanvas *CanvasState \`json:"-"\` \tAnnotations *AnnotationsState \`json:"-"\` } @@ -1090,6 +1111,8 @@ func (s SnapshotState) MarshalJSON() ([]byte, error) { \t\treturn json.Marshal(s.Changeset) \tcase s.ResourceWatch != nil: \t\treturn json.Marshal(s.ResourceWatch) +\tcase s.Canvas != nil: +\t\treturn json.Marshal(s.Canvas) \tcase s.Annotations != nil: \t\treturn json.Marshal(s.Annotations) \tcase s.Root != nil: @@ -1138,6 +1161,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { \t\t\treturn err \t\t} \t\ts.ResourceWatch = &v +\tcase containsAll(probe, "canvasId", "provider"): +\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, "annotations"): \t\tvar v AnnotationsState \t\tif err := json.Unmarshal(data, &v); err != nil { @@ -1223,6 +1252,7 @@ function generateStateFile(project: Project): string { lines.push(generateDiscriminatedUnion(TOOL_CALL_CONTRIBUTOR_UNION)); lines.push(''); lines.push(generateDiscriminatedUnion(SESSION_INPUT_REQUEST_UNION)); + lines.push(generateDiscriminatedUnion(CANVAS_PROVIDER_SOURCE_UNION)); lines.push(''); lines.push(generateChatOriginGo()); lines.push(''); @@ -1278,6 +1308,8 @@ const ACTION_VARIANTS: { { type: 'session/activityChanged', variantName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', variantName: 'SessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', variantName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, + { type: 'session/canvasesChanged', variantName: 'SessionCanvasesChanged', tsInterface: 'SessionCanvasesChangedAction' }, + { type: 'session/openCanvasesChanged', variantName: 'SessionOpenCanvasesChanged', tsInterface: 'SessionOpenCanvasesChangedAction' }, { type: 'session/activeClientSet', variantName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', variantName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'session/inputNeededSet', variantName: 'SessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction' }, @@ -1314,6 +1346,9 @@ const ACTION_VARIANTS: { { type: 'terminal/commandExecuted', variantName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', variantName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', variantName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'canvas/updated', variantName: 'CanvasUpdated', tsInterface: 'CanvasUpdatedAction' }, + { type: 'canvas/closeRequested', variantName: 'CanvasCloseRequested', tsInterface: 'CanvasCloseRequestedAction' }, + { type: 'canvas/message', variantName: 'CanvasMessage', tsInterface: 'CanvasMessageAction' }, ]; function generateMergedChatToolCallConfirmedStruct(): string { @@ -1449,6 +1484,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'CompletionsParams' }, { name: 'CompletionItem' }, { name: 'CompletionsResult' }, { name: 'InvokeChangesetOperationParams' }, { name: 'InvokeChangesetOperationResult' }, { name: 'ChangesetOperationFollowUp' }, + { name: 'CanvasOpenParams' }, { name: 'CanvasOpenResult' }, + { name: 'CanvasInvokeActionParams' }, { name: 'CanvasInvokeActionResult' }, + { name: 'CanvasCloseParams' }, + { name: 'CanvasReadResourceParams' }, { name: 'CanvasReadResourceResult' }, + { name: 'CanvasResourceContent' }, ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -1662,6 +1702,8 @@ const ( \tErrorCodeNotFound int32 = -32008 \tErrorCodePermissionDenied int32 = -32009 \tErrorCodeAlreadyExists int32 = -32010 +\tErrorCodeConflict int32 = -32011 +\tErrorCodeCanvasProviderError int32 = -32012 ) // AhpErrorCode is the type alias used by AHP application error codes. @@ -1689,6 +1731,14 @@ type PermissionDeniedErrorData struct { type UnsupportedProtocolVersionErrorData struct { \tSupportedVersions []string \`json:"supportedVersions"\` } + +// CanvasProviderErrorData is the detail payload of a +// CanvasProviderError (-32012) error. The Code is a provider-defined +// string (opaque to AHP) identifying the failure. +type CanvasProviderErrorData struct { +\tCode string \`json:"code"\` +\tMessage string \`json:"message"\` +} `; } @@ -1923,11 +1973,13 @@ function checkExhaustiveness(project: Project): void { 'McpServerState', 'ToolCallContributor', 'SessionInputRequest', + 'CanvasProviderSource', 'ToolCallConfirmationState', 'ReconnectResult', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', + 'CanvasProviderErrorData', 'AhpError', 'AhpErrorDetailsMap', 'AhpErrorCode', diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 01a9fe46..f95fc6a6 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -147,6 +147,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | CanvasState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || @@ -660,7 +661,7 @@ internal object StringOrMarkdownSerializer : KSerializer { function generateSnapshotState(): string { return `/** * The state payload of a snapshot — root, session, chat, terminal, changeset, - * resource-watch, or annotations state. + * resource-watch, canvas, or annotations state. */ @Serializable(with = SnapshotStateSerializer::class) sealed interface SnapshotState { @@ -670,6 +671,7 @@ sealed interface SnapshotState { @JvmInline value class Terminal(val value: TerminalState) : SnapshotState @JvmInline value class Changeset(val value: ChangesetState) : SnapshotState @JvmInline value class ResourceWatch(val value: ResourceWatchState) : SnapshotState + @JvmInline value class Canvas(val value: CanvasState) : SnapshotState @JvmInline value class Annotations(val value: AnnotationsState) : SnapshotState } @@ -686,7 +688,8 @@ internal object SnapshotStateSerializer : KSerializer { // Try the most distinctive shape first. SessionState has required // \`lifecycle\`; ChatState has required \`turns\`; ChangesetState has // required \`status\` + \`files\`; ResourceWatchState has required - // \`root\` + \`recursive\`; AnnotationsState has required \`annotations\` + // \`root\` + \`recursive\`; CanvasState has required \`canvasId\` + + // \`provider\`; AnnotationsState has required \`annotations\` // (checked after session, whose optional annotations summary reuses the // key); TerminalState has required \`content\`; RootState is the // catch-all. @@ -697,6 +700,8 @@ internal object SnapshotStateSerializer : KSerializer { SnapshotState.Changeset(input.json.decodeFromJsonElement(ChangesetState.serializer(), element)) obj.containsKey("root") && obj.containsKey("recursive") -> SnapshotState.ResourceWatch(input.json.decodeFromJsonElement(ResourceWatchState.serializer(), element)) + obj.containsKey("canvasId") && obj.containsKey("provider") -> + SnapshotState.Canvas(input.json.decodeFromJsonElement(CanvasState.serializer(), element)) obj.containsKey("annotations") -> SnapshotState.Annotations(input.json.decodeFromJsonElement(AnnotationsState.serializer(), element)) obj.containsKey("content") -> @@ -715,6 +720,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Terminal -> output.json.encodeToJsonElement(TerminalState.serializer(), value.value) is SnapshotState.Changeset -> output.json.encodeToJsonElement(ChangesetState.serializer(), value.value) is SnapshotState.ResourceWatch -> output.json.encodeToJsonElement(ResourceWatchState.serializer(), value.value) + is SnapshotState.Canvas -> output.json.encodeToJsonElement(CanvasState.serializer(), value.value) is SnapshotState.Annotations -> output.json.encodeToJsonElement(AnnotationsState.serializer(), value.value) } output.encodeJsonElement(element) @@ -793,6 +799,7 @@ const STATE_ENUMS = [ 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', + 'CanvasAvailability', 'CanvasProviderKind', ]; const STATE_STRUCTS = [ @@ -843,6 +850,8 @@ const STATE_STRUCTS = [ 'AnnotationsSummary', 'AnnotationsState', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', + 'SessionCanvasAction', 'SessionCanvasDeclaration', 'ClientCanvasDeclaration', 'OpenCanvasRef', + 'CanvasServerProviderSource', 'CanvasClientProviderSource', 'CanvasState', ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1081,6 +1090,16 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { unknown: true, }; +const CANVAS_PROVIDER_SOURCE_UNION: UnionConfig = { + name: 'CanvasProviderSource', + discriminantField: 'kind', + variants: [ + { caseName: 'Server', structName: 'CanvasServerProviderSource', discriminantValue: 'server' }, + { caseName: 'Client', structName: 'CanvasClientProviderSource', discriminantValue: 'client' }, + ], + unknown: true, +}; + function generateStateFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1150,6 +1169,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(SESSION_INPUT_REQUEST_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(CANVAS_PROVIDER_SOURCE_UNION)); + lines.push(''); lines.push(generateToolResultContentUnion()); lines.push(''); lines.push(generateSnapshotState()); @@ -1191,6 +1212,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/activityChanged', caseName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', caseName: 'SessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', caseName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, + { type: 'session/canvasesChanged', caseName: 'SessionCanvasesChanged', tsInterface: 'SessionCanvasesChangedAction' }, + { type: 'session/openCanvasesChanged', caseName: 'SessionOpenCanvasesChanged', tsInterface: 'SessionOpenCanvasesChangedAction' }, { type: 'session/activeClientSet', caseName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', caseName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'session/inputNeededSet', caseName: 'SessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction' }, @@ -1236,6 +1259,9 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'terminal/commandExecuted', caseName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', caseName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', caseName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'canvas/updated', caseName: 'CanvasUpdated', tsInterface: 'CanvasUpdatedAction' }, + { type: 'canvas/closeRequested', caseName: 'CanvasCloseRequested', tsInterface: 'CanvasCloseRequestedAction' }, + { type: 'canvas/message', caseName: 'CanvasMessage', tsInterface: 'CanvasMessageAction' }, ]; /** Merged data class for the approved/denied tool call confirmed action. */ @@ -1423,6 +1449,10 @@ const COMMAND_STRUCTS = [ 'CompletionsParams', 'CompletionItem', 'CompletionsResult', 'InvokeChangesetOperationParams', 'InvokeChangesetOperationResult', 'ChangesetOperationFollowUp', + 'CanvasOpenParams', 'CanvasOpenResult', + 'CanvasInvokeActionParams', 'CanvasInvokeActionResult', + 'CanvasCloseParams', + 'CanvasReadResourceParams', 'CanvasReadResourceResult', 'CanvasResourceContent', ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -1659,12 +1689,16 @@ function generateErrorsFile(project: Project): string { lines.push(' const val PERMISSION_DENIED: Int = -32009'); lines.push(' /** The target resource already exists and the operation does not allow overwriting */'); lines.push(' const val ALREADY_EXISTS: Int = -32010'); + lines.push(' /** An optimistic-concurrency precondition failed: a request precondition token no longer matches the resource state */'); + lines.push(' const val CONFLICT: Int = -32011'); + lines.push(' /** A canvas provider request (canvasOpen, canvasInvokeAction, or canvasClose) failed; `data` carries a provider-defined `{ code, message }` */'); + lines.push(' const val CANVAS_PROVIDER_ERROR: Int = -32012'); lines.push('}'); lines.push(''); lines.push('// ─── Error Detail Payloads ──────────────────────────────────────────────────'); lines.push(''); - for (const ifaceName of ['AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData']) { + for (const ifaceName of ['AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', 'CanvasProviderErrorData']) { try { lines.push(generateDataClassFromInterface(project, ifaceName)); lines.push(''); @@ -1910,12 +1944,14 @@ function checkExhaustiveness(project: Project): void { 'McpServerState', // MCP_SERVER_STATUS_UNION discriminated union 'ToolCallContributor', // TOOL_CALL_CONTRIBUTOR_UNION discriminated union 'SessionInputRequest', // SESSION_INPUT_REQUEST_UNION discriminated union + 'CanvasProviderSource', // CANVAS_PROVIDER_SOURCE_UNION discriminated union 'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union 'ChildCustomizationType', // TS subset alias of CustomizationType; consumers reuse CustomizationType 'CustomizationLoadState', // CUSTOMIZATION_LOAD_STATE_UNION discriminated union 'AuthRequiredErrorData', // emitted by generateErrorsFile() 'PermissionDeniedErrorData', // emitted by generateErrorsFile() 'UnsupportedProtocolVersionErrorData', // emitted by generateErrorsFile() + 'CanvasProviderErrorData', // emitted by generateErrorsFile() 'AhpError', // typed via JsonRpcError; not a Kotlin data class 'AhpErrorDetailsMap', // type-level mapping; not a Kotlin type 'AhpErrorCode', // type-level alias over AhpErrorCodes const enum diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 3d408b9a..a2004557 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -158,6 +158,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | CanvasState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -659,6 +660,7 @@ const STATE_ENUMS = [ 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', + 'CanvasAvailability', 'CanvasProviderKind', ]; /** @@ -799,6 +801,13 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'TelemetryCapabilities' }, { name: 'ResourceWatchState' }, { name: 'ResourceChange' }, + { name: 'SessionCanvasAction' }, + { name: 'SessionCanvasDeclaration' }, + { name: 'ClientCanvasDeclaration' }, + { name: 'OpenCanvasRef' }, + { name: 'CanvasServerProviderSource', omitDiscriminants: true }, + { name: 'CanvasClientProviderSource', omitDiscriminants: true }, + { name: 'CanvasState' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1014,6 +1023,17 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { unknown: true, }; +const CANVAS_PROVIDER_SOURCE_UNION: UnionConfig = { + name: 'CanvasProviderSource', + discriminantField: 'kind', + doc: 'Where a canvas declaration came from — used for request routing and cleanup when its provider disconnects.', + variants: [ + { variantName: 'Server', innerType: 'CanvasServerProviderSource', wireValue: 'server' }, + { variantName: 'Client', innerType: 'CanvasClientProviderSource', wireValue: 'client' }, + ], + unknown: true, +}; + function generateChatOrigin(): string { return `/// How a chat came into existence. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1049,12 +1069,13 @@ pub enum ChatOrigin { function generateSnapshotState(): string { return `/// The state payload of a snapshot — root, session, chat, terminal, -/// changeset, resource-watch, or annotations state. +/// changeset, resource-watch, canvas, or annotations state. /// /// Deserialized by trying session first (has required \`lifecycle\`), then /// chat (has required \`turns\`), then terminal (has required \`content\`), /// then changeset (has required \`status\` and \`files\`), then resource-watch -/// (has required \`root\` and \`recursive\`), then annotations (has required +/// (has required \`root\` and \`recursive\`), then canvas (has required +/// \`canvasId\` and \`provider\`), then annotations (has required /// \`annotations\`), then root. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] @@ -1064,6 +1085,7 @@ pub enum SnapshotState { Terminal(Box), Changeset(Box), ResourceWatch(Box), + Canvas(Box), Annotations(Box), Root(Box), }`; @@ -1136,6 +1158,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(SESSION_INPUT_REQUEST_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(CANVAS_PROVIDER_SOURCE_UNION)); + lines.push(''); lines.push(generateSnapshotState()); lines.push(''); @@ -1183,6 +1207,8 @@ const ACTION_VARIANTS: { { type: 'session/activityChanged', variantName: 'SessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', variantName: 'SessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', variantName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, + { type: 'session/canvasesChanged', variantName: 'SessionCanvasesChanged', tsInterface: 'SessionCanvasesChangedAction' }, + { type: 'session/openCanvasesChanged', variantName: 'SessionOpenCanvasesChanged', tsInterface: 'SessionOpenCanvasesChangedAction' }, { type: 'session/activeClientSet', variantName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', variantName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'session/inputNeededSet', variantName: 'SessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction', boxed: true }, @@ -1227,6 +1253,9 @@ const ACTION_VARIANTS: { { type: 'terminal/commandExecuted', variantName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', variantName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', variantName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'canvas/updated', variantName: 'CanvasUpdated', tsInterface: 'CanvasUpdatedAction' }, + { type: 'canvas/closeRequested', variantName: 'CanvasCloseRequested', tsInterface: 'CanvasCloseRequestedAction' }, + { type: 'canvas/message', variantName: 'CanvasMessage', tsInterface: 'CanvasMessageAction' }, ]; function generateMergedToolCallConfirmedStruct(scope: 'Session' | 'Chat' = 'Session'): string { @@ -1265,7 +1294,7 @@ pub struct ${scope}ToolCallConfirmedAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, Customization, ErrorInfo, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, Customization, ErrorInfo, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolResultContent, UsageInfo, Message, PendingMessageKind, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary, CanvasAvailability, OpenCanvasRef, SessionCanvasDeclaration};'); lines.push(''); // ActionType enum @@ -1399,6 +1428,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'CompletionsParams' }, { name: 'CompletionItem' }, { name: 'CompletionsResult' }, { name: 'InvokeChangesetOperationParams' }, { name: 'InvokeChangesetOperationResult' }, { name: 'ChangesetOperationFollowUp' }, + { name: 'CanvasOpenParams' }, { name: 'CanvasOpenResult' }, + { name: 'CanvasInvokeActionParams' }, { name: 'CanvasInvokeActionResult' }, + { name: 'CanvasCloseParams' }, + { name: 'CanvasReadResourceParams' }, { name: 'CanvasReadResourceResult' }, + { name: 'CanvasResourceContent' }, ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -1610,6 +1644,8 @@ pub mod ahp_error_codes { pub const ALREADY_EXISTS: i32 = -32010; /// An optimistic-concurrency precondition failed: a request's precondition token (e.g. \`ResourceWriteParams.if_match\`) no longer matches the resource's current state. pub const CONFLICT: i32 = -32011; + /// A canvas provider request (\`canvasOpen\`, \`canvasInvokeAction\`, or \`canvasClose\`) failed; the error \`data\` carries a provider-defined \`{ code, message }\`. + pub const CANVAS_PROVIDER_ERROR: i32 = -32012; } /// Type alias: AHP application error code. @@ -1648,6 +1684,17 @@ pub struct UnsupportedProtocolVersionErrorData { /// SemVer range constraint (e.g. \`">=0.1.0 <0.3.0"\` or \`"^0.2.0"\`). pub supported_versions: Vec, } + +/// Details carried in the \`data\` field of a \`CanvasProviderError\` (-32012) +/// error. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderErrorData { + /// Provider-defined error code identifying the failure. + pub code: String, + /// Human-readable error message. + pub message: String, +} `; } @@ -1820,11 +1867,13 @@ function checkExhaustiveness(project: Project): void { 'McpServerState', // MCP_SERVER_STATUS_UNION discriminated union 'ToolCallContributor', // TOOL_CALL_CONTRIBUTOR_UNION discriminated union 'SessionInputRequest', // SESSION_INPUT_REQUEST_UNION discriminated union + 'CanvasProviderSource', // CANVAS_PROVIDER_SOURCE_UNION discriminated union 'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union 'ReconnectResult', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', + 'CanvasProviderErrorData', 'AhpError', 'AhpErrorDetailsMap', 'AhpErrorCode', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index d32c7630..4566404b 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -114,6 +114,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | CanvasState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -546,6 +547,7 @@ const STATE_ENUMS = [ 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', + 'CanvasAvailability', 'CanvasProviderKind', ]; const STATE_STRUCTS = [ @@ -596,6 +598,8 @@ const STATE_STRUCTS = [ 'AnnotationsSummary', 'AnnotationsState', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', + 'SessionCanvasAction', 'SessionCanvasDeclaration', 'ClientCanvasDeclaration', 'OpenCanvasRef', + 'CanvasServerProviderSource', 'CanvasClientProviderSource', 'CanvasState', ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -795,6 +799,17 @@ const SESSION_INPUT_REQUEST_UNION: UnionConfig = { ], }; +const CANVAS_PROVIDER_SOURCE_UNION: UnionConfig = { + name: 'CanvasProviderSource', + discriminantField: 'kind', + // Open union: future protocol versions may add new canvas provider kinds. + allowUnknown: true, + variants: [ + { caseName: 'server', structName: 'CanvasServerProviderSource', discriminantValue: 'server' }, + { caseName: 'client', structName: 'CanvasClientProviderSource', discriminantValue: 'client' }, + ], +}; + function generateToolResultContentUnion(): string { return `public enum ToolResultContent: Codable, Sendable { case text(ToolResultTextContent) @@ -885,7 +900,7 @@ public enum StringOrMarkdown: Codable, Sendable, Equatable { } function generateSnapshotState(): string { - return `/// The state payload of a snapshot — root, session, chat, terminal, changeset, resource-watch, or annotations state. + return `/// The state payload of a snapshot — root, session, chat, terminal, changeset, resource-watch, canvas, or annotations state. public enum SnapshotState: Codable, Sendable { case root(RootState) case session(SessionState) @@ -893,6 +908,7 @@ public enum SnapshotState: Codable, Sendable { case terminal(TerminalState) case changeset(ChangesetState) case resourceWatch(ResourceWatchState) + case canvas(CanvasState) case annotations(AnnotationsState) public init(from decoder: Decoder) throws { @@ -910,6 +926,8 @@ public enum SnapshotState: Codable, Sendable { self = .changeset(changeset) } else if let resourceWatch = try? ResourceWatchState(from: decoder) { self = .resourceWatch(resourceWatch) + } else if let canvas = try? CanvasState(from: decoder) { + self = .canvas(canvas) } else if let annotations = try? AnnotationsState(from: decoder) { self = .annotations(annotations) } else { @@ -925,6 +943,7 @@ public enum SnapshotState: Codable, Sendable { case .terminal(let state): try state.encode(to: encoder) case .changeset(let state): try state.encode(to: encoder) case .resourceWatch(let state): try state.encode(to: encoder) + case .canvas(let state): try state.encode(to: encoder) case .annotations(let state): try state.encode(to: encoder) } } @@ -1057,6 +1076,8 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(SESSION_INPUT_REQUEST_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(CANVAS_PROVIDER_SOURCE_UNION)); + lines.push(''); lines.push(generateToolResultContentUnion()); lines.push(''); lines.push(generateSnapshotState()); @@ -1099,6 +1120,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/activityChanged', caseName: 'sessionActivityChanged', tsInterface: 'SessionActivityChangedAction' }, { type: 'session/changesetsChanged', caseName: 'sessionChangesetsChanged', tsInterface: 'SessionChangesetsChangedAction' }, { type: 'session/serverToolsChanged', caseName: 'sessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, + { type: 'session/canvasesChanged', caseName: 'sessionCanvasesChanged', tsInterface: 'SessionCanvasesChangedAction' }, + { type: 'session/openCanvasesChanged', caseName: 'sessionOpenCanvasesChanged', tsInterface: 'SessionOpenCanvasesChangedAction' }, { type: 'session/activeClientSet', caseName: 'sessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', caseName: 'sessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, { type: 'session/inputNeededSet', caseName: 'sessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction' }, @@ -1144,6 +1167,9 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'terminal/commandExecuted', caseName: 'terminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', caseName: 'terminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', caseName: 'resourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'canvas/updated', caseName: 'canvasUpdated', tsInterface: 'CanvasUpdatedAction' }, + { type: 'canvas/closeRequested', caseName: 'canvasCloseRequested', tsInterface: 'CanvasCloseRequestedAction' }, + { type: 'canvas/message', caseName: 'canvasMessage', tsInterface: 'CanvasMessageAction' }, ]; /** Merged struct for the approved/denied tool call confirmed action */ @@ -1340,6 +1366,10 @@ const COMMAND_STRUCTS = [ 'CompletionsParams', 'CompletionItem', 'CompletionsResult', 'InvokeChangesetOperationParams', 'InvokeChangesetOperationResult', 'ChangesetOperationFollowUp', + 'CanvasOpenParams', 'CanvasOpenResult', + 'CanvasInvokeActionParams', 'CanvasInvokeActionResult', + 'CanvasCloseParams', + 'CanvasReadResourceParams', 'CanvasReadResourceResult', 'CanvasResourceContent', ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -1569,11 +1599,15 @@ function generateErrorsFile(project: Project): string { lines.push(' public static let permissionDenied = -32009'); lines.push(' /// The target resource already exists and the operation does not allow overwriting'); lines.push(' public static let alreadyExists = -32010'); + lines.push(' /// An optimistic-concurrency precondition failed: a request precondition token no longer matches the resource state'); + lines.push(' public static let conflict = -32011'); + lines.push(' /// A canvas provider request (canvasOpen, canvasInvokeAction, or canvasClose) failed; `data` carries a provider-defined `{ code, message }`'); + lines.push(' public static let canvasProviderError = -32012'); lines.push('}'); lines.push(''); lines.push('// MARK: - Error Detail Payloads\n'); - for (const ifaceName of ['AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData']) { + for (const ifaceName of ['AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', 'CanvasProviderErrorData']) { try { lines.push(generateStructFromInterface(project, ifaceName)); lines.push(''); @@ -1931,10 +1965,12 @@ function checkExhaustiveness(project: Project): void { 'McpServerState', // MCP_SERVER_STATUS_UNION discriminated union 'ToolCallContributor', // TOOL_CALL_CONTRIBUTOR_UNION discriminated union 'SessionInputRequest', // SESSION_INPUT_REQUEST_UNION discriminated union + 'CanvasProviderSource', // CANVAS_PROVIDER_SOURCE_UNION discriminated union 'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union 'AuthRequiredErrorData', // emitted by generateErrorsFile() 'PermissionDeniedErrorData', // emitted by generateErrorsFile() 'UnsupportedProtocolVersionErrorData', // emitted by generateErrorsFile() + 'CanvasProviderErrorData', // emitted by generateErrorsFile() 'AhpError', // typed via JsonRpcError; not a Swift struct 'AhpErrorDetailsMap', // type-level mapping; not a Swift struct 'AhpErrorCode', // type-level alias over AhpErrorCodes const enum diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 150dbc1a..832c56d5 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -78,6 +78,11 @@ import type { TerminalCommandExecutedAction, TerminalCommandFinishedAction, ResourceWatchChangedAction, + SessionCanvasesChangedAction, + SessionOpenCanvasesChangedAction, + CanvasUpdatedAction, + CanvasCloseRequestedAction, + CanvasMessageAction, } from './actions.js'; import { ActionType } from './actions.js'; @@ -129,6 +134,8 @@ export type SessionAction = | SessionChangesetsChangedAction | SessionConfigChangedAction | SessionMetaChangedAction + | SessionCanvasesChangedAction + | SessionOpenCanvasesChangedAction ; /** Union of session actions that clients may dispatch. */ @@ -160,6 +167,8 @@ export type ServerSessionAction = | SessionActivityChangedAction | SessionChangesetsChangedAction | SessionMetaChangedAction + | SessionCanvasesChangedAction + | SessionOpenCanvasesChangedAction ; /** Union of all chat-scoped actions. */ @@ -321,6 +330,24 @@ export type ServerResourceWatchAction = | ResourceWatchChangedAction ; +/** Union of all canvas-scoped actions. */ +export type CanvasAction = + | CanvasUpdatedAction + | CanvasCloseRequestedAction + | CanvasMessageAction +; + +/** Union of canvas actions that clients may dispatch. */ +export type ClientCanvasAction = + | CanvasCloseRequestedAction + | CanvasMessageAction +; + +/** Union of canvas actions that only the server may produce. */ +export type ServerCanvasAction = + | CanvasUpdatedAction +; + // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** @@ -403,4 +430,9 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.TerminalCommandExecuted]: false, [ActionType.TerminalCommandFinished]: false, [ActionType.ResourceWatchChanged]: false, + [ActionType.SessionCanvasesChanged]: false, + [ActionType.SessionOpenCanvasesChanged]: false, + [ActionType.CanvasUpdated]: false, + [ActionType.CanvasCloseRequested]: true, + [ActionType.CanvasMessage]: true, }; diff --git a/types/actions.ts b/types/actions.ts index a2469d66..f792f39d 100644 --- a/types/actions.ts +++ b/types/actions.ts @@ -15,3 +15,4 @@ export * from './channels-terminal/actions.js'; export * from './channels-changeset/actions.js'; export * from './channels-annotations/actions.js'; export * from './channels-resource-watch/actions.js'; +export * from './channels-canvas/actions.js'; diff --git a/types/channels-canvas/actions.ts b/types/channels-canvas/actions.ts new file mode 100644 index 00000000..25e8ef03 --- /dev/null +++ b/types/channels-canvas/actions.ts @@ -0,0 +1,73 @@ +/** + * Canvas Channel Actions — Mutations of an `ahp-canvas:` channel's state. + * + * @module channels-canvas/actions + */ + +import { ActionType } from '../common/actions.js'; +import type { CanvasAvailability } from '../channels-session/state.js'; + +// ─── Canvas Actions ────────────────────────────────────────────────────────── + +/** + * The canvas instance's presentation state changed. + * + * Sparse-merge semantics: each present field overwrites the corresponding + * {@link CanvasState} field, and an absent field preserves the current value. + * There is no clear-to-absent via this action — that three-state distinction + * cannot survive JSON transport uniformly across languages, so a provider that + * needs to reset a field re-publishes it, and a full reset arrives as a fresh + * {@link CanvasState} snapshot on (re)subscribe. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasUpdatedAction { + type: ActionType.CanvasUpdated; + /** New title. Absent preserves the current title. */ + title?: string; + /** New provider-defined status. Absent preserves the current status. */ + status?: string; + /** New content address. Absent preserves the current url. */ + url?: string; + /** New availability. Absent preserves the current availability. */ + availability?: CanvasAvailability; +} + +/** + * The user asked to close this canvas (e.g. hit the ✕ on the surface). + * + * A pure client→host signal with a no-op reducer, mirroring how + * `terminal/input` is a side-effect-only client action. The host runs the + * close flow in response — resolving `canvasClose` against the provider and + * dropping the instance from {@link SessionState.openCanvases} — rather than + * the reducer mutating channel state. + * + * @category Canvas Actions + * @version 1 + * @clientDispatchable + */ +export interface CanvasCloseRequestedAction { + type: ActionType.CanvasCloseRequested; +} + +/** + * An opaque message relayed between the rendered canvas View and the + * instance's provider — the relay-carried analogue of a `postMessage` bridge. + * + * Bidirectional: a client dispatches it to carry a View→provider message, and + * the host emits it to carry a provider→View message (routed to the provider + * resolved for the instance, or handled host-internally for a server-side + * provider). Like `terminal/input` and {@link CanvasCloseRequestedAction} it + * is a pure signal with a no-op reducer, so it never bloats channel state. See + * {@link /specification/canvas-channel | Canvas Channel}. + * + * @category Canvas Actions + * @version 1 + * @clientDispatchable + */ +export interface CanvasMessageAction { + type: ActionType.CanvasMessage; + /** Opaque, provider-defined message payload. */ + payload: unknown; +} diff --git a/types/channels-canvas/commands.ts b/types/channels-canvas/commands.ts new file mode 100644 index 00000000..8046970f --- /dev/null +++ b/types/channels-canvas/commands.ts @@ -0,0 +1,221 @@ +/** + * Canvas Channel Commands — the server → client provider request family + * (`canvasOpen` / `canvasInvokeAction` / `canvasClose`) and the client → + * server `canvasReadResource` content-fetch request. + * + * @module channels-canvas/commands + */ + +import type { URI } from '../common/state.js'; +import type { BaseParams } from '../common/commands.js'; + +// ─── canvasOpen ──────────────────────────────────────────────────────────── + +/** + * Opens a canvas instance against its provider. + * + * Sent by the host to the client that declared the target canvas via + * {@link SessionActiveClient.canvasProviders} (a client that also declared + * {@link ClientCapabilities.canvas}). For a server-side provider the host + * resolves the open host-internally and emits no request. The provider returns + * the initial render target and presentation fields, which the host folds into + * the new instance's {@link CanvasState}. + * + * Mirrors the `resource*` precedent: registered in `ServerCommandMap` and + * mirrored in `CommandMap` for symmetry. A client normally never initiates it — + * the host is not a canvas provider — and a receiver SHOULD reject a request + * whose target is not one of its declared providers. + * + * @category Commands + * @method canvasOpen + * @direction Client ↔ Server + * @messageType Request + * @version 1 + * @throws `CanvasProviderError` (`-32012`) if the provider cannot open the + * canvas; `data` carries the provider-defined `{ code, message }`. + * @example + * ```jsonc + * // Server → Client + * { "jsonrpc": "2.0", "id": 41, "method": "canvasOpen", + * "params": { + * "channel": "ahp-session:/2f9c…", + * "canvasId": "diff", + * "extensionId": "acme.canvases", + * "instanceId": "inst-7", + * "input": { "path": "src/app.ts" } + * } } + * + * // Client → Server + * { "jsonrpc": "2.0", "id": 41, "result": { + * "url": "https://acme.example/canvas/inst-7", + * "title": "src/app.ts" + * } } + * ``` + */ +export interface CanvasOpenParams extends BaseParams { + /** The owning session channel URI (`ahp-session:/`). */ + channel: URI; + /** Provider-local canvas id to open. */ + canvasId: string; + /** Owning provider id. */ + extensionId: string; + /** Caller-minted handle for the new instance. */ + instanceId: string; + /** Open input, validated by the provider against its declared schema. */ + input?: Record; +} + +/** + * Result of the `canvasOpen` command. + */ +export interface CanvasOpenResult { + /** Initial content address for the instance (see {@link CanvasState.url}). */ + url?: string; + /** Initial title. */ + title?: string; + /** Initial provider-defined status. */ + status?: string; +} + +// ─── canvasInvokeAction ────────────────────────────────────────────────────── + +/** + * Invokes one of a canvas's declared actions against its provider. + * + * Sent by the host to the providing client (or resolved host-internally for a + * server-side provider) when the agent invokes a + * {@link SessionCanvasAction | declared action} on an open instance. The + * provider returns an opaque, provider-defined value. Registered symmetrically + * with the rest of the provider family (see {@link CanvasOpenParams}). + * + * @category Commands + * @method canvasInvokeAction + * @direction Client ↔ Server + * @messageType Request + * @version 1 + * @throws `CanvasProviderError` (`-32012`) if the provider has no handler for + * the action (`canvas_action_no_handler`) or the invocation fails; `data` + * carries the provider-defined `{ code, message }`. + */ +export interface CanvasInvokeActionParams extends BaseParams { + /** The owning session channel URI (`ahp-session:/`). */ + channel: URI; + /** Instance handle the action targets. */ + instanceId: string; + /** Provider-local canvas id of the instance. */ + canvasId: string; + /** Owning provider id. */ + extensionId: string; + /** Declared action name to invoke. */ + actionName: string; + /** Action input, validated by the provider against its declared schema. */ + input?: Record; +} + +/** + * Result of the `canvasInvokeAction` command. + */ +export interface CanvasInvokeActionResult { + /** Opaque, provider-defined return value. */ + value?: unknown; +} + +// ─── canvasClose ───────────────────────────────────────────────────────────── + +/** + * Closes a canvas instance against its provider. + * + * Sent by the host to the providing client (or resolved host-internally for a + * server-side provider) as part of the close flow — typically after a client + * dispatches `canvas/closeRequested`. The host then drops the instance from + * {@link SessionState.openCanvases}. Registered symmetrically with the rest of + * the provider family (see {@link CanvasOpenParams}). + * + * @category Commands + * @method canvasClose + * @direction Client ↔ Server + * @messageType Request + * @version 1 + */ +export interface CanvasCloseParams extends BaseParams { + /** The owning session channel URI (`ahp-session:/`). */ + channel: URI; + /** Instance handle to close. */ + instanceId: string; + /** Provider-local canvas id of the instance. */ + canvasId: string; + /** Owning provider id. */ + extensionId: string; +} + +// ─── canvasReadResource ────────────────────────────────────────────────────── + +/** + * Reads channel-served canvas content by `ahp-canvas-content:` URI. + * + * A client → host request, modeled on MCP's `resources/read`. When a canvas's + * {@link CanvasState.url} (or a sub-resource the rendered document references) + * is an `ahp-canvas-content://` address, the renderer cannot + * dial the host directly — for example in a relayed deployment behind a broker + * — so it resolves the bytes over the instance's existing `ahp-canvas:/` + * channel with this request instead of loading a network URL. The `` + * segment of the URI identifies which canvas channel to read from. See + * {@link /specification/canvas-channel | Canvas Channel}. + * + * @category Commands + * @method canvasReadResource + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the content URI does not resolve. + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 52, "method": "canvasReadResource", + * "params": { + * "channel": "ahp-canvas:/9b1e…", + * "uri": "ahp-canvas-content:/inst-7/index.html" + * } } + * + * // Server → Client + * { "jsonrpc": "2.0", "id": 52, "result": { + * "contents": [ + * { "uri": "ahp-canvas-content:/inst-7/index.html", + * "mimeType": "text/html", "text": "…" } + * ] + * } } + * ``` + */ +export interface CanvasReadResourceParams extends BaseParams { + /** The owning canvas channel URI (`ahp-canvas:/`). */ + channel: URI; + /** An `ahp-canvas-content://` content URI to read. */ + uri: string; +} + +/** + * Result of the `canvasReadResource` command. + */ +export interface CanvasReadResourceResult { + /** The resolved content parts, wrapped for forward compatibility. */ + contents: CanvasResourceContent[]; +} + +/** + * One resolved piece of channel-served canvas content. + * + * Carries exactly one of {@link text} (text payloads) or {@link blob} + * (base64-encoded binary payloads). + * + * @category Commands + */ +export interface CanvasResourceContent { + /** The content URI this part resolves. */ + uri: string; + /** MIME type of the content, when known. */ + mimeType?: string; + /** UTF-8 text content, for text payloads. */ + text?: string; + /** Base64-encoded content, for binary payloads. */ + blob?: string; +} diff --git a/types/channels-canvas/reducer.ts b/types/channels-canvas/reducer.ts new file mode 100644 index 00000000..a29b6022 --- /dev/null +++ b/types/channels-canvas/reducer.ts @@ -0,0 +1,45 @@ +/** + * Canvas Channel Reducer — Pure reducer for `CanvasState`. + * + * @module channels-canvas/reducer + */ + +import { ActionType } from '../common/actions.js'; +import type { CanvasState } from './state.js'; +import type { CanvasAction } from '../action-origin.generated.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; + +/** + * Pure reducer for canvas state. Handles all {@link CanvasAction} variants. + * + * `canvas/updated` is a sparse merge — a present field overwrites the + * corresponding {@link CanvasState} field and an absent field preserves the + * current value. `canvas/closeRequested` and `canvas/message` are pure + * signals with no state effect (the host acts on them out of band), mirroring + * how `terminal/input` is side-effect-only. + */ +export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (msg: string) => void): CanvasState { + switch (action.type) { + case ActionType.CanvasUpdated: + return { + ...state, + title: action.title ?? state.title, + status: action.status ?? state.status, + url: action.url ?? state.url, + availability: action.availability ?? state.availability, + }; + + case ActionType.CanvasCloseRequested: + // Side-effect-only: a client→host "user hit ✕" signal. The host runs + // the close flow; the reducer keeps no state for it. + return state; + + case ActionType.CanvasMessage: + // Side-effect-only: an opaque View↔provider relay message. No state. + return state; + + 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..8a45c1df --- /dev/null +++ b/types/channels-canvas/state.ts @@ -0,0 +1,65 @@ +/** + * Canvas Channel State Types — Per-open-instance state exposed on + * `ahp-canvas:` channels. + * + * @module channels-canvas/state + */ + +import type { + CanvasAvailability, + CanvasProviderSource, +} from '../channels-session/state.js'; + +// ─── Canvas State ──────────────────────────────────────────────────────────── + +/** + * Full state for a single open canvas instance, delivered when a client + * subscribes to the instance's `ahp-canvas:/` channel. + * + * One channel exists per open instance — the same "one channel per resource" + * convention used by terminals, changesets, and resource watches. The + * lightweight catalogue entry that advertises this channel is + * {@link OpenCanvasRef} on {@link SessionState.openCanvases}; this state is the + * authoritative, mutable per-instance view a renderer reads. + * + * Rendering is state-driven: a client renders the canvas by reading + * {@link url} and resolving it per the renderer's URL policy — directly for a + * reachable address, or over this channel via `canvasReadResource` for an + * `ahp-canvas-content:` address. It never receives a "render this" request. + * + * @category Canvas State + */ +export interface CanvasState { + /** Server-assigned instance handle, unique within the session. */ + instanceId: string; + /** Provider-local canvas id this instance was opened from. */ + canvasId: string; + /** Owning provider id. */ + extensionId: string; + /** Human-readable provider name. */ + extensionName?: string; + /** Human-readable canvas name. */ + displayName?: string; + /** + * Input the agent supplied when opening the instance. Retained so the + * instance can be resumed or rebound after a reconnect. + */ + input?: Record; + /** Current instance title. */ + title?: string; + /** Provider-defined status string (opaque to AHP). */ + status?: string; + /** + * Renderer-targeted address for the opaque canvas content — either a + * directly-loadable URL (`https:`, an in-process scheme, `http://localhost`) + * or a channel-served `ahp-canvas-content://` address the + * renderer resolves over this channel with `canvasReadResource`. The + * renderer dispatches on the scheme and enforces its URL policy. See + * {@link /specification/canvas-channel | Canvas Channel}. + */ + url?: string; + /** Whether this instance's provider is currently available. */ + availability: CanvasAvailability; + /** Which provider owns the callbacks (`canvasOpen` / … ) for this instance. */ + provider: CanvasProviderSource; +} diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 6cf40e58..f0138ca1 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -10,6 +10,8 @@ import type { ToolDefinition, SessionActiveClient, SessionInputRequest, + SessionCanvasDeclaration, + OpenCanvasRef, Customization, McpServerState, } from './state.js'; @@ -255,6 +257,41 @@ export interface SessionActiveClientRemovedAction { clientId: string; } +// ─── Canvas Actions ────────────────────────────────────────────────────────── + +/** + * The aggregated canvas registry for this session changed. + * + * Full-replacement semantics: the `canvases` array replaces + * {@link SessionState.canvases} entirely, mirroring + * `session/serverToolsChanged`. The host republishes the union of every + * connected provider (server-side and client-declared) whenever it changes. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCanvasesChangedAction { + type: ActionType.SessionCanvasesChanged; + /** Updated canvas registry (full replacement). */ + canvases: SessionCanvasDeclaration[]; +} + +/** + * The catalogue of open canvas instances for this session changed. + * + * Full-replacement semantics: the `openCanvases` array replaces + * {@link SessionState.openCanvases} entirely. The host republishes the + * catalogue as instances open and close. + * + * @category Session Actions + * @version 1 + */ +export interface SessionOpenCanvasesChangedAction { + type: ActionType.SessionOpenCanvasesChanged; + /** Updated open-instance catalogue (full replacement). */ + openCanvases: OpenCanvasRef[]; +} + // ─── Input Needed Actions ──────────────────────────────────────────────────── /** diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index ca0e2985..eb09859c 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -156,6 +156,12 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionServerToolsChanged: return { ...state, serverTools: action.tools }; + case ActionType.SessionCanvasesChanged: + return { ...state, canvases: action.canvases }; + + case ActionType.SessionOpenCanvasesChanged: + return { ...state, openCanvases: action.openCanvases }; + case ActionType.SessionActiveClientSet: { const list = state.activeClients; const idx = list.findIndex(c => c.clientId === action.activeClient.clientId); diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index 2e875e65..ce8b3690 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -186,6 +186,29 @@ export interface SessionState extends SessionMetadata { * once the underlying request resolves. */ inputNeeded?: SessionInputRequest[]; + /** + * Aggregated canvas registry currently exposed to the agent — the union of + * every connected provider (server-side and client-declared). Each entry + * describes a canvas the agent can open; the host folds + * {@link SessionActiveClient.canvasProviders | client-declared providers} + * into this list alongside its own server-side providers. + * + * Full-replacement via `session/canvasesChanged`. Populated only when at + * least one connected client declared {@link ClientCapabilities.canvas}; + * absent for sessions with no canvas surface. See + * {@link /specification/canvas-channel | Canvas Channel}. + */ + canvases?: SessionCanvasDeclaration[]; + /** + * Lightweight catalogue of currently-open canvas instances. Each entry + * carries the instance's `ahp-canvas:/` channel URI so a subscriber can + * subscribe to the full {@link CanvasState} and render it — analogous to how + * {@link RootState.terminals} catalogues live terminals whose full state + * lives on each terminal channel. + * + * Full-replacement via `session/openCanvasesChanged`. + */ + openCanvases?: OpenCanvasRef[]; /** * Additional provider-specific metadata for this session. * @@ -221,9 +244,182 @@ export interface SessionActiveClient { * children inside {@link SessionState.customizations}. */ customizations?: ClientPluginCustomization[]; + /** + * Canvas declarations this client contributes as a provider. Published + * atomically with the rest of the active-client entry via + * `session/activeClientSet` — exactly like {@link tools} — so there is no + * separate canvas-provider change action. The host folds these into + * {@link SessionState.canvases} with + * {@link SessionCanvasDeclaration.source | `source`} set to + * `{ kind: 'client', clientId }` and routes + * `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for them back + * to this client. Only meaningful for a client that declared + * {@link ClientCapabilities.canvas}. + */ + canvasProviders?: ClientCanvasDeclaration[]; +} + +// ─── Canvas Declarations ───────────────────────────────────────────────────── + +/** + * Availability of a canvas provider or open instance. + * + * @category Canvas Types + */ +export const enum CanvasAvailability { + /** The provider is connected and can service requests for this canvas. */ + Ready = 'ready', + /** + * The provider is temporarily unavailable (for example, a client provider + * that disconnected). The entry is retained so it can be restored when the + * provider reconnects; in-flight requests fail until then. + */ + Stale = 'stale', } -// ─── Session Input Requests ────────────────────────────────────────────────── +/** + * Discriminant for {@link CanvasProviderSource}. + * + * @category Canvas Types + */ +export const enum CanvasProviderKind { + /** The canvas is provided by the host itself. */ + Server = 'server', + /** The canvas is provided by a connected client. */ + Client = 'client', +} + +/** + * A canvas provided by the host. Carries no `clientId` — server-side provider + * requests are resolved host-internally rather than routed to a peer. + * + * @category Canvas Types + */ +export interface CanvasServerProviderSource { + kind: CanvasProviderKind.Server; +} + +/** + * A canvas provided by a connected client. The host routes + * `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for this canvas + * to the identified client. + * + * @category Canvas Types + */ +export interface CanvasClientProviderSource { + kind: CanvasProviderKind.Client; + /** `clientId` of the providing client (matches `initialize`). */ + clientId: string; +} + +/** + * Where a canvas declaration came from — used for request routing and for + * cleaning the entry up when its provider disconnects. Modeled as a + * discriminated union so the `clientId` is only present (and required) for the + * client variant. + * + * @category Canvas Types + */ +export type CanvasProviderSource = + | CanvasServerProviderSource + | CanvasClientProviderSource; + +/** + * One named action a canvas exposes to the agent, mirroring the shape of a + * {@link ToolDefinition} entry. Unique within its owning + * `(extensionId, canvasId)`. + * + * @category Canvas Types + */ +export interface SessionCanvasAction { + /** Action name, unique within the owning `(extensionId, canvasId)`. */ + name: string; + /** Human-readable description of what the action does. */ + description?: string; + /** + * JSON Schema for the action's input. Opaque to AHP; mirrors the + * {@link ToolDefinition.inputSchema} shape. + */ + inputSchema?: Record; +} + +/** + * One entry in the aggregated {@link SessionState.canvases} registry — a canvas + * the agent can open, contributed by a server-side or client-declared provider. + * + * @category Canvas Types + */ +export interface SessionCanvasDeclaration { + /** Owning provider id. Stable across declarations and instances. */ + extensionId: string; + /** Human-readable provider name. */ + extensionName?: string; + /** Provider-local canvas id. Unique within `extensionId`. */ + canvasId: string; + /** Human-readable canvas name. */ + displayName: string; + /** Human-readable description of the canvas. */ + description: string; + /** + * JSON Schema for the canvas's open input. Opaque to AHP; mirrors the + * {@link ToolDefinition.inputSchema} shape. + */ + inputSchema?: Record; + /** Actions this canvas exposes to the agent. */ + actions?: SessionCanvasAction[]; + /** Where the declaration came from — for routing and cleanup. */ + source: CanvasProviderSource; +} + +/** + * The lighter declaration shape a client publishes on + * {@link SessionActiveClient.canvasProviders}. The host derives the + * `extensionId` and {@link SessionCanvasDeclaration.source | `source`} when + * folding it into {@link SessionState.canvases}. + * + * @category Canvas Types + */ +export interface ClientCanvasDeclaration { + /** Provider-local canvas id, unique within the publishing client. */ + canvasId: string; + /** Human-readable canvas name. */ + displayName: string; + /** Human-readable description of the canvas. */ + description: string; + /** JSON Schema for the canvas's open input. Opaque to AHP. */ + inputSchema?: Record; + /** Actions this canvas exposes to the agent. */ + actions?: SessionCanvasAction[]; +} + +/** + * A lightweight catalogue entry for one open canvas instance, surfaced on + * {@link SessionState.openCanvases}. The authoritative, mutable per-instance + * state lives on the instance's own {@link CanvasState} channel; this entry + * exists so a subscriber can discover the channel URI and render it without + * subscribing to every instance. + * + * @category Canvas Types + */ +export interface OpenCanvasRef { + /** Server-assigned instance handle, unique within the session. */ + instanceId: string; + /** + * The instance's channel URI (`ahp-canvas:/`). Subscribe to it to load + * the full {@link CanvasState}. + */ + channel: URI; + /** Provider-local canvas id this instance was opened from. */ + canvasId: string; + /** Owning provider id. */ + extensionId: string; + /** Human-readable provider name. */ + extensionName?: string; + /** Current instance title, mirrored from {@link CanvasState.title}. */ + title?: string; + /** Whether the instance's provider is currently available. */ + availability: CanvasAvailability; +} /** * Discriminant for the kinds of outstanding input a session can surface in diff --git a/types/commands.ts b/types/commands.ts index c77dd949..8602e1c3 100644 --- a/types/commands.ts +++ b/types/commands.ts @@ -14,3 +14,4 @@ export * from './channels-chat/commands.js'; export * from './channels-terminal/commands.js'; export * from './channels-changeset/commands.js'; export * from './channels-resource-watch/commands.js'; +export * from './channels-canvas/commands.js'; diff --git a/types/common/actions.ts b/types/common/actions.ts index 474dc9c3..8f5cc471 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -39,6 +39,8 @@ import type { SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction, + SessionCanvasesChangedAction, + SessionOpenCanvasesChangedAction, } from '../channels-session/actions.js'; import type { @@ -104,6 +106,12 @@ import type { ResourceWatchChangedAction, } from '../channels-resource-watch/actions.js'; +import type { + CanvasUpdatedAction, + CanvasCloseRequestedAction, + CanvasMessageAction, +} from '../channels-canvas/actions.js'; + // ─── Action Type Enum ──────────────────────────────────────────────────────── /** @@ -187,6 +195,11 @@ export const enum ActionType { TerminalCommandExecuted = 'terminal/commandExecuted', TerminalCommandFinished = 'terminal/commandFinished', ResourceWatchChanged = 'resourceWatch/changed', + SessionCanvasesChanged = 'session/canvasesChanged', + SessionOpenCanvasesChanged = 'session/openCanvasesChanged', + CanvasUpdated = 'canvas/updated', + CanvasCloseRequested = 'canvas/closeRequested', + CanvasMessage = 'canvas/message', } // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -297,4 +310,9 @@ export type StateAction = | TerminalCommandDetectionAvailableAction | TerminalCommandExecutedAction | TerminalCommandFinishedAction - | ResourceWatchChangedAction; + | ResourceWatchChangedAction + | SessionCanvasesChangedAction + | SessionOpenCanvasesChangedAction + | CanvasUpdatedAction + | CanvasCloseRequestedAction + | CanvasMessageAction; diff --git a/types/common/commands.ts b/types/common/commands.ts index c674d52e..3d0a4dac 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -167,6 +167,18 @@ export interface ClientCapabilities { * App-bearing tool calls as ordinary MCP tool calls. */ mcpApps?: Record; + /** + * Client can render canvases and host client-declared canvas providers — it + * can render an opaque canvas URL in an isolated surface, and it can answer + * `canvasOpen` / `canvasInvokeAction` / `canvasClose` requests for canvases + * it declares via {@link SessionActiveClient.canvasProviders}. + * + * Hosts SHOULD only populate {@link SessionState.canvases} / + * {@link SessionState.openCanvases} and only route canvas requests to a + * client that declared this capability. Clients that omit it see no canvas + * surface. See {@link /specification/canvas-channel | Canvas Channel}. + */ + canvas?: Record; } /** diff --git a/types/common/errors.ts b/types/common/errors.ts index 7c1d62bc..736f75a1 100644 --- a/types/common/errors.ts +++ b/types/common/errors.ts @@ -95,6 +95,15 @@ export const AhpErrorCodes = { * fresh token or surface the conflict to the user. */ Conflict: -32011, + /** + * A canvas provider request (`canvasOpen`, `canvasInvokeAction`, or + * `canvasClose`) failed. The `data` field of the JSON-RPC error MUST be a + * {@link CanvasProviderErrorData} carrying the provider-defined + * `{ code, message }` — for example a `canvas_action_no_handler` code when + * the provider declared the canvas but has no handler for the requested + * action, or `canvas_provider_unavailable` when the provider is disconnected. + */ + CanvasProviderError: -32012, } as const; /** Union type of all AHP application error codes. */ @@ -157,6 +166,24 @@ export interface UnsupportedProtocolVersionErrorData { supportedVersions: string[]; } +/** + * Details carried in the `data` field of a `CanvasProviderError` (-32012) + * error. + * + * The `code` is a provider-defined string (opaque to AHP) identifying the + * failure — observed values include `canvas_action_no_handler` and + * `canvas_provider_unavailable`. `message` is a human-readable description. + * + * @category Error Details + * @version 1 + */ +export interface CanvasProviderErrorData { + /** Provider-defined error code identifying the failure. */ + code: string; + /** Human-readable error message. */ + message: string; +} + /** * Maps each AHP error code that carries structured `data` to the type of * that data. @@ -171,6 +198,7 @@ export interface AhpErrorDetailsMap { [AhpErrorCodes.AuthRequired]: AuthRequiredErrorData; [AhpErrorCodes.PermissionDenied]: PermissionDeniedErrorData; [AhpErrorCodes.UnsupportedProtocolVersion]: UnsupportedProtocolVersionErrorData; + [AhpErrorCodes.CanvasProviderError]: CanvasProviderErrorData; } /** AHP error codes that carry a structured `data` payload. */ diff --git a/types/common/messages.ts b/types/common/messages.ts index 14918c98..2b9f2410 100644 --- a/types/common/messages.ts +++ b/types/common/messages.ts @@ -70,6 +70,15 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult, } from '../channels-changeset/commands.js'; +import type { + CanvasOpenParams, + CanvasOpenResult, + CanvasInvokeActionParams, + CanvasInvokeActionResult, + CanvasCloseParams, + CanvasReadResourceParams, + CanvasReadResourceResult, +} from '../channels-canvas/commands.js'; import type { ActionEnvelope } from './actions.js'; import type { @@ -174,6 +183,10 @@ export interface CommandMap { 'sessionConfigCompletions': { params: SessionConfigCompletionsParams; result: SessionConfigCompletionsResult }; 'completions': { params: CompletionsParams; result: CompletionsResult }; 'invokeChangesetOperation': { params: InvokeChangesetOperationParams; result: InvokeChangesetOperationResult }; + 'canvasOpen': { params: CanvasOpenParams; result: CanvasOpenResult }; + 'canvasInvokeAction': { params: CanvasInvokeActionParams; result: CanvasInvokeActionResult }; + 'canvasClose': { params: CanvasCloseParams; result: null }; + 'canvasReadResource': { params: CanvasReadResourceParams; result: CanvasReadResourceResult }; } /** @@ -188,6 +201,13 @@ export interface CommandMap { * `virtual://my-client/...` plugins) and to drive per-session filesystem * providers without the client having to re-implement the wire schema. * + * The `canvas*` provider family (`canvasOpen` / `canvasInvokeAction` / + * `canvasClose`) is server → client: the host drives a client-declared + * canvas provider. It is mirrored in {@link CommandMap} for symmetry with + * the `resource*` precedent; a client normally never initiates it, and a + * receiver SHOULD reject a request whose target is not one of its declared + * providers. + * * @category Commands */ export interface ServerCommandMap { @@ -201,6 +221,9 @@ export interface ServerCommandMap { 'resourceMkdir': { params: ResourceMkdirParams; result: ResourceMkdirResult }; 'resourceRequest': { params: ResourceRequestParams; result: ResourceRequestResult }; 'createResourceWatch': { params: CreateResourceWatchParams; result: CreateResourceWatchResult }; + 'canvasOpen': { params: CanvasOpenParams; result: CanvasOpenResult }; + 'canvasInvokeAction': { params: CanvasInvokeActionParams; result: CanvasInvokeActionResult }; + 'canvasClose': { params: CanvasCloseParams; result: null }; } // ─── Notification Maps ─────────────────────────────────────────────────────── diff --git a/types/common/state.ts b/types/common/state.ts index a451c5f1..a6041041 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -12,6 +12,7 @@ import type { SessionState } from '../channels-session/state.js'; import type { TerminalState } from '../channels-terminal/state.js'; import type { ChangesetState } from '../channels-changeset/state.js'; import type { ResourceWatchState } from '../channels-resource-watch/state.js'; +import type { CanvasState } from '../channels-canvas/state.js'; import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; @@ -337,7 +338,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; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | CanvasState | AnnotationsState | ChatState; /** 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 ccec8598..a66c1268 100644 --- a/types/index.ts +++ b/types/index.ts @@ -110,6 +110,14 @@ export type { TelemetryCapabilities, ResourceWatchState, ResourceChange, + CanvasState, + SessionCanvasAction, + SessionCanvasDeclaration, + ClientCanvasDeclaration, + OpenCanvasRef, + CanvasServerProviderSource, + CanvasClientProviderSource, + CanvasProviderSource, } from './state.js'; export { @@ -137,6 +145,8 @@ export { ChangesetOperationStatus, ChangesetOperationScope, ResourceChangeType, + CanvasAvailability, + CanvasProviderKind, } from './state.js'; // Action types @@ -213,6 +223,11 @@ export type { TerminalCommandFinishedAction, TerminalCommandDetectionAvailableAction, ResourceWatchChangedAction, + SessionCanvasesChangedAction, + SessionOpenCanvasesChangedAction, + CanvasUpdatedAction, + CanvasCloseRequestedAction, + CanvasMessageAction, } from './actions.js'; export { ActionType } from './actions.js'; @@ -238,6 +253,9 @@ export type { ResourceWatchAction, ClientResourceWatchAction, ServerResourceWatchAction, + CanvasAction, + ClientCanvasAction, + ServerCanvasAction, } from './action-origin.generated.js'; export { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; @@ -251,6 +269,7 @@ export { changesetReducer, annotationsReducer, resourceWatchReducer, + canvasReducer, isClientDispatchable, } from './reducers.js'; @@ -322,6 +341,14 @@ export type { InvokeChangesetOperationResult, ChangesetOperationTarget, ChangesetOperationFollowUp, + CanvasOpenParams, + CanvasOpenResult, + CanvasInvokeActionParams, + CanvasInvokeActionResult, + CanvasCloseParams, + CanvasReadResourceParams, + CanvasReadResourceResult, + CanvasResourceContent, } from './commands.js'; export { ReconnectResultType, ContentEncoding, CompletionItemKind, ResourceType, ResourceWriteMode } from './commands.js'; @@ -377,6 +404,7 @@ export type { AhpErrorDetailsMap, AuthRequiredErrorData, PermissionDeniedErrorData, + CanvasProviderErrorData, } from './errors.js'; // Version registry diff --git a/types/messages.test.ts b/types/messages.test.ts index 591a9d7c..8fe726bb 100644 --- a/types/messages.test.ts +++ b/types/messages.test.ts @@ -33,6 +33,7 @@ function readChannelSources(baseName: string): string { 'channels-changeset', 'channels-annotations', 'channels-resource-watch', + 'channels-canvas', ]; return dirs .map(dir => { diff --git a/types/reducers.test.ts b/types/reducers.test.ts index f3a8c7de..e2bf929d 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -25,11 +25,12 @@ import { changesetReducer, annotationsReducer, resourceWatchReducer, + 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 } from './state.js'; +import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, CanvasState } from './state.js'; import { SessionStatus, TurnState, @@ -54,6 +55,7 @@ function readChannelSources(baseName: string): string { 'channels-changeset', 'channels-annotations', 'channels-resource-watch', + 'channels-canvas', ]; return dirs .map(dir => { @@ -140,6 +142,8 @@ describe('reducer fixtures', () => { state = annotationsReducer(state as AnnotationsState, action as any); } else if (fixture.reducer === 'resourceWatch') { state = resourceWatchReducer(state as ResourceWatchState, 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); } diff --git a/types/reducers.ts b/types/reducers.ts index d916ba3a..759aa4af 100644 --- a/types/reducers.ts +++ b/types/reducers.ts @@ -12,4 +12,5 @@ export { terminalReducer } from './channels-terminal/reducer.js'; export { changesetReducer } from './channels-changeset/reducer.js'; export { annotationsReducer } from './channels-annotations/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/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 8748e48d..072cfaa5 100644 --- a/types/state.ts +++ b/types/state.ts @@ -16,3 +16,4 @@ export * from './channels-changeset/state.js'; export * from './channels-annotations/state.js'; export * from './channels-otlp/state.js'; export * from './channels-resource-watch/state.js'; +export * from './channels-canvas/state.js'; diff --git a/types/test-cases/reducers/232-session-canvaseschanged-sets-registry.json b/types/test-cases/reducers/232-session-canvaseschanged-sets-registry.json new file mode 100644 index 00000000..dca3d0b5 --- /dev/null +++ b/types/test-cases/reducers/232-session-canvaseschanged-sets-registry.json @@ -0,0 +1,65 @@ +{ + "description": "session/canvasesChanged replaces state.canvases with the new registry", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/canvasesChanged", + "canvases": [ + { + "extensionId": "acme.editors", + "extensionName": "Acme Editors", + "canvasId": "markdown", + "displayName": "Markdown Editor", + "description": "Edit and preview Markdown documents.", + "source": { "kind": "server" } + }, + { + "extensionId": "acme.viewers", + "canvasId": "image", + "displayName": "Image Viewer", + "description": "Preview raster and vector images.", + "actions": [ + { "name": "zoom", "description": "Zoom the image to a factor." } + ], + "source": { "kind": "client", "clientId": "client-42" } + } + ] + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "canvases": [ + { + "extensionId": "acme.editors", + "extensionName": "Acme Editors", + "canvasId": "markdown", + "displayName": "Markdown Editor", + "description": "Edit and preview Markdown documents.", + "source": { "kind": "server" } + }, + { + "extensionId": "acme.viewers", + "canvasId": "image", + "displayName": "Image Viewer", + "description": "Preview raster and vector images.", + "actions": [ + { "name": "zoom", "description": "Zoom the image to a factor." } + ], + "source": { "kind": "client", "clientId": "client-42" } + } + ], + "activeClients": [], + "chats": [] + } +} diff --git a/types/test-cases/reducers/233-session-opencanvaseschanged-sets-catalogue.json b/types/test-cases/reducers/233-session-opencanvaseschanged-sets-catalogue.json new file mode 100644 index 00000000..51d94580 --- /dev/null +++ b/types/test-cases/reducers/233-session-opencanvaseschanged-sets-catalogue.json @@ -0,0 +1,61 @@ +{ + "description": "session/openCanvasesChanged replaces state.openCanvases with the new catalogue", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/openCanvasesChanged", + "openCanvases": [ + { + "instanceId": "canvas-1", + "channel": "ahp-canvas:/canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "extensionName": "Acme Editors", + "title": "README.md", + "availability": "ready" + }, + { + "instanceId": "canvas-2", + "channel": "ahp-canvas:/canvas-2", + "canvasId": "image", + "extensionId": "acme.viewers", + "availability": "stale" + } + ] + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "openCanvases": [ + { + "instanceId": "canvas-1", + "channel": "ahp-canvas:/canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "extensionName": "Acme Editors", + "title": "README.md", + "availability": "ready" + }, + { + "instanceId": "canvas-2", + "channel": "ahp-canvas:/canvas-2", + "canvasId": "image", + "extensionId": "acme.viewers", + "availability": "stale" + } + ], + "activeClients": [], + "chats": [] + } +} diff --git a/types/test-cases/reducers/234-canvas-updated-merges-all-fields.json b/types/test-cases/reducers/234-canvas-updated-merges-all-fields.json new file mode 100644 index 00000000..db2ac17d --- /dev/null +++ b/types/test-cases/reducers/234-canvas-updated-merges-all-fields.json @@ -0,0 +1,37 @@ +{ + "description": "canvas/updated overwrites every presented field (title, status, url, availability)", + "reducer": "canvas", + "initial": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "extensionName": "Acme Editors", + "displayName": "Markdown Editor", + "title": "Draft", + "status": "idle", + "url": "ahp-canvas-content:/canvas-1/index.html", + "availability": "ready", + "provider": { "kind": "server" } + }, + "actions": [ + { + "type": "canvas/updated", + "title": "Published", + "status": "saved", + "url": "https://example.com/docs/published.html", + "availability": "stale" + } + ], + "expected": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "extensionName": "Acme Editors", + "displayName": "Markdown Editor", + "title": "Published", + "status": "saved", + "url": "https://example.com/docs/published.html", + "availability": "stale", + "provider": { "kind": "server" } + } +} diff --git a/types/test-cases/reducers/235-canvas-updated-partial-preserves-absent.json b/types/test-cases/reducers/235-canvas-updated-partial-preserves-absent.json new file mode 100644 index 00000000..a26256f9 --- /dev/null +++ b/types/test-cases/reducers/235-canvas-updated-partial-preserves-absent.json @@ -0,0 +1,31 @@ +{ + "description": "canvas/updated with only title and url present preserves the absent status and availability", + "reducer": "canvas", + "initial": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Draft", + "status": "idle", + "url": "ahp-canvas-content:/canvas-1/index.html", + "availability": "ready", + "provider": { "kind": "client", "clientId": "client-42" } + }, + "actions": [ + { + "type": "canvas/updated", + "title": "Renamed", + "url": "https://example.com/docs/renamed.html" + } + ], + "expected": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Renamed", + "status": "idle", + "url": "https://example.com/docs/renamed.html", + "availability": "ready", + "provider": { "kind": "client", "clientId": "client-42" } + } +} diff --git a/types/test-cases/reducers/236-canvas-updated-partial-complementary.json b/types/test-cases/reducers/236-canvas-updated-partial-complementary.json new file mode 100644 index 00000000..17419321 --- /dev/null +++ b/types/test-cases/reducers/236-canvas-updated-partial-complementary.json @@ -0,0 +1,31 @@ +{ + "description": "canvas/updated with only status and availability present preserves the absent title and url", + "reducer": "canvas", + "initial": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Draft", + "status": "idle", + "url": "ahp-canvas-content:/canvas-1/index.html", + "availability": "ready", + "provider": { "kind": "server" } + }, + "actions": [ + { + "type": "canvas/updated", + "status": "error", + "availability": "stale" + } + ], + "expected": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Draft", + "status": "error", + "url": "ahp-canvas-content:/canvas-1/index.html", + "availability": "stale", + "provider": { "kind": "server" } + } +} diff --git a/types/test-cases/reducers/237-canvas-closerequested-no-op.json b/types/test-cases/reducers/237-canvas-closerequested-no-op.json new file mode 100644 index 00000000..ef06ab56 --- /dev/null +++ b/types/test-cases/reducers/237-canvas-closerequested-no-op.json @@ -0,0 +1,25 @@ +{ + "description": "canvas/closeRequested is a side-effect-only signal — the reducer keeps state unchanged", + "reducer": "canvas", + "initial": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Draft", + "availability": "ready", + "provider": { "kind": "server" } + }, + "actions": [ + { + "type": "canvas/closeRequested" + } + ], + "expected": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Draft", + "availability": "ready", + "provider": { "kind": "server" } + } +} diff --git a/types/test-cases/reducers/238-canvas-message-no-op.json b/types/test-cases/reducers/238-canvas-message-no-op.json new file mode 100644 index 00000000..b340763e --- /dev/null +++ b/types/test-cases/reducers/238-canvas-message-no-op.json @@ -0,0 +1,26 @@ +{ + "description": "canvas/message is a side-effect-only relay signal — the reducer keeps state unchanged", + "reducer": "canvas", + "initial": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Draft", + "availability": "ready", + "provider": { "kind": "client", "clientId": "client-42" } + }, + "actions": [ + { + "type": "canvas/message", + "payload": { "kind": "scrollTo", "line": 120 } + } + ], + "expected": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "title": "Draft", + "availability": "ready", + "provider": { "kind": "client", "clientId": "client-42" } + } +} diff --git a/types/test-cases/reducers/239-canvas-unknown-action-type-is-no-op.json b/types/test-cases/reducers/239-canvas-unknown-action-type-is-no-op.json new file mode 100644 index 00000000..d21e3de6 --- /dev/null +++ b/types/test-cases/reducers/239-canvas-unknown-action-type-is-no-op.json @@ -0,0 +1,23 @@ +{ + "description": "canvas unknown action type is no-op", + "reducer": "canvas", + "initial": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "availability": "ready", + "provider": { "kind": "server" } + }, + "actions": [ + { + "type": "canvas/nonExistentAction" + } + ], + "expected": { + "instanceId": "canvas-1", + "canvasId": "markdown", + "extensionId": "acme.editors", + "availability": "ready", + "provider": { "kind": "server" } + } +} diff --git a/types/version/message-checks.ts b/types/version/message-checks.ts index 8da4dddf..e93cad05 100644 --- a/types/version/message-checks.ts +++ b/types/version/message-checks.ts @@ -76,7 +76,11 @@ type _ExpectedCommands = | 'resolveSessionConfig' | 'sessionConfigCompletions' | 'completions' - | 'invokeChangesetOperation'; + | 'invokeChangesetOperation' + | 'canvasOpen' + | 'canvasInvokeAction' + | 'canvasClose' + | 'canvasReadResource'; /** All methods annotated `@messageType Notification` (client → server). */ type _ExpectedClientNotifications = @@ -106,7 +110,10 @@ type _ExpectedServerCommands = | 'resourceResolve' | 'resourceMkdir' | 'resourceRequest' - | 'createResourceWatch'; + | 'createResourceWatch' + | 'canvasOpen' + | 'canvasInvokeAction' + | 'canvasClose'; // ─── Assertions ────────────────────────────────────────────────────────────── diff --git a/types/version/registry.ts b/types/version/registry.ts index 3969004c..68668a24 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.5.1'; +export const PROTOCOL_VERSION = '0.6.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,6 +34,7 @@ export const PROTOCOL_VERSION = '0.5.1'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.6.0', '0.5.1', '0.5.0', ]); @@ -151,6 +152,11 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.TerminalCommandExecuted]: '0.1.0', [ActionType.TerminalCommandFinished]: '0.1.0', [ActionType.ResourceWatchChanged]: '0.2.0', + [ActionType.SessionCanvasesChanged]: '0.6.0', + [ActionType.SessionOpenCanvasesChanged]: '0.6.0', + [ActionType.CanvasUpdated]: '0.6.0', + [ActionType.CanvasCloseRequested]: '0.6.0', + [ActionType.CanvasMessage]: '0.6.0', }; /** From c5ca36b0a8a2f1d5e4304d43dc474956808dda3d Mon Sep 17 00:00:00 2001 From: Colby Williams Date: Wed, 1 Jul 2026 21:54:37 -0500 Subject: [PATCH 2/4] Fix Rust fmt and clippy in canvas reducer - Reflow the `ahp_types::state` import block to satisfy `cargo fmt`. - Copy `CanvasAvailability` by deref instead of `.clone()` (it is `Copy`), fixing `clippy::clone_on_copy` under `-D warnings`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- clients/rust/crates/ahp/src/reducers.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 688d154e..7869280a 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -58,15 +58,15 @@ use ahp_types::actions::{ ChatToolCallResultConfirmedAction, ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, CanvasState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, - ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, ErrorInfo, - PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, - SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, - TerminalContentPart, TerminalState, TerminalUnclassifiedPart, ToolCallCancellationReason, - ToolCallCancelledState, ToolCallCompletedState, ToolCallConfirmationReason, - ToolCallContributor, ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, - ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStreamingState, Turn, - TurnState, + ActiveTurn, AnnotationsState, CanvasState, ChangesetOperationStatus, ChangesetState, + ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, + Customization, ErrorInfo, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, + RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + TerminalCommandPart, TerminalContentPart, TerminalState, TerminalUnclassifiedPart, + ToolCallCancellationReason, ToolCallCancelledState, ToolCallCompletedState, + ToolCallConfirmationReason, ToolCallContributor, ToolCallPendingConfirmationState, + ToolCallPendingResultConfirmationState, ToolCallResponsePart, ToolCallRunningState, + ToolCallState, ToolCallStreamingState, Turn, TurnState, }; /// What happened when an action was applied. @@ -1636,7 +1636,7 @@ pub fn apply_action_to_canvas(state: &mut CanvasState, action: &StateAction) -> state.url = Some(url.clone()); } if let Some(availability) = &a.availability { - state.availability = availability.clone(); + state.availability = *availability; } ReduceOutcome::Applied } From c5ff126eab491d3c93f52683516dd3e1744370fe Mon Sep 17 00:00:00 2001 From: Colby Williams Date: Wed, 1 Jul 2026 22:29:09 -0500 Subject: [PATCH 3/4] Address canvas PR review: client-dispatchable canvas/updated + doc fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peer review of the canvas PR surfaced one design gap and three doc issues, each validated against the code before changing: - canvas/updated is now @clientDispatchable. The PR already models client-side canvas providers (ClientCanvasDeclaration, the client CanvasProviderSource variant, CanvasAvailability.Stale), and title / availability are mirrored onto OpenCanvasRef for other subscribers, yet canvas/updated was server-only — leaving a client-side provider no wire path to push its own presentation changes. This mirrors the established terminal/titleChanged pattern (a client may originate it; the host validates, applies, and re-broadcasts). Regenerated: IS_CLIENT_DISPATCHABLE now maps canvas/updated to true, ClientCanvasAction gains it, and ServerCanvasAction becomes `never`. - Fixed the canvas-channel.md sequence diagram to reference the real session/activeClientSet action (there is no session/activeClientsChanged). - Documented that a View to provider canvas/message routes to the single resolved provider, and that multi-renderer provider to View targeting is deferred to the open render-targeting question. - Clarified the deliberate channel split in the command family (session-scoped provider RPCs vs. instance-scoped canvasReadResource). No client reducer/mirror changes are needed: the dispatchability split lives only in the canonical action-origin table; the per-language reducers switch on action type. Verified across TS core (291 tests), Rust (fmt + clippy + test), Go, Swift, Kotlin, the TS client, and the docs build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- clients/go/ahptypes/actions.generated.go | 9 +++++++++ clients/rust/crates/ahp-types/src/actions.rs | 9 +++++++++ docs/specification/canvas-channel.md | 14 +++++++++----- schema/actions.schema.json | 2 +- schema/commands.schema.json | 2 +- types/action-origin.generated.ts | 5 +++-- types/channels-canvas/actions.ts | 10 ++++++++++ 7 files changed, 42 insertions(+), 9 deletions(-) diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 1b56fbb6..76ee77b5 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -1263,6 +1263,15 @@ type ResourceWatchChangedAction struct { // The canvas instance's presentation state changed. // +// Emitted by the server for a server-side provider, or dispatched by the client +// that provides the instance to push its own presentation changes — mirroring +// how `terminal/titleChanged` lets a client-owned terminal rename itself. This +// is the only path a client-side provider has to update the structured +// {@link CanvasState} (and the {@link OpenCanvasRef} fields mirrored from it) +// that other subscribers render. The host stays the authoritative reducer: it +// SHOULD reject an update from a client that is not the instance's resolved +// provider, then applies the merge and re-broadcasts to subscribers. +// // Sparse-merge semantics: each present field overwrites the corresponding // {@link CanvasState} field, and an absent field preserves the current value. // There is no clear-to-absent via this action — that three-state distinction diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 539427a1..a42119e1 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -1499,6 +1499,15 @@ pub struct ResourceWatchChangedAction { /// The canvas instance's presentation state changed. /// +/// Emitted by the server for a server-side provider, or dispatched by the client +/// that provides the instance to push its own presentation changes — mirroring +/// how `terminal/titleChanged` lets a client-owned terminal rename itself. This +/// is the only path a client-side provider has to update the structured +/// {@link CanvasState} (and the {@link OpenCanvasRef} fields mirrored from it) +/// that other subscribers render. The host stays the authoritative reducer: it +/// SHOULD reject an update from a client that is not the instance's resolved +/// provider, then applies the merge and re-broadcasts to subscribers. +/// /// Sparse-merge semantics: each present field overwrites the corresponding /// {@link CanvasState} field, and an absent field preserves the current value. /// There is no clear-to-absent via this action — that three-state distinction diff --git a/docs/specification/canvas-channel.md b/docs/specification/canvas-channel.md index a1ab74b6..a79615ca 100644 --- a/docs/specification/canvas-channel.md +++ b/docs/specification/canvas-channel.md @@ -140,13 +140,15 @@ Three actions travel on the per-instance channel. Because the channel is scoped | Action | Client-dispatchable | Reducer effect | |---|:---:|---| -| `canvas/updated` | No | Sparse-merges `title` / `status` / `url` / `availability` into `CanvasState`. | +| `canvas/updated` | Yes | Sparse-merges `title` / `status` / `url` / `availability` into `CanvasState`. | | `canvas/closeRequested` | Yes | No-op — signals the host to run the close flow. | | `canvas/message` | Yes | No-op — opaque View ↔ provider message bridge. | -`canvas/updated` uses **sparse-merge** semantics: each present field overwrites the corresponding `CanvasState` field, and an absent field preserves the current value. There is no clear-to-absent through this action — a provider that needs to reset a field re-publishes it, and a full reset arrives as a fresh `CanvasState` snapshot on (re)subscribe. +`canvas/updated` is dispatched by the server for a server-side provider, or by the client that provides an instance to push its own presentation changes — the same client-dispatchable pattern as `terminal/titleChanged`, and the only way a client-side provider updates the structured `CanvasState` (and the `OpenCanvasRef` fields mirrored from it) that other subscribers render. The host stays the authoritative reducer: it SHOULD reject an update from a client that is not the instance's resolved provider, then applies the merge and re-broadcasts. -`canvas/closeRequested` and `canvas/message` are pure signals with no-op reducers, mirroring how `terminal/input` is a side-effect-only client action — they never bloat channel state. `canvas/closeRequested` is a client → host signal (the user hit ✕); the host responds by resolving `canvasClose` against the provider and dropping the instance from `openCanvases`. `canvas/message` is bidirectional: a client dispatches it to carry a View → provider message, and the host emits it to carry a provider → View message. +It uses **sparse-merge** semantics: each present field overwrites the corresponding `CanvasState` field, and an absent field preserves the current value. There is no clear-to-absent through this action — a provider that needs to reset a field re-publishes it, and a full reset arrives as a fresh `CanvasState` snapshot on (re)subscribe. + +`canvas/closeRequested` and `canvas/message` are pure signals with no-op reducers, mirroring how `terminal/input` is a side-effect-only client action — they never bloat channel state. `canvas/closeRequested` is a client → host signal (the user hit ✕); the host responds by resolving `canvasClose` against the provider and dropping the instance from `openCanvases`. `canvas/message` is bidirectional: a client dispatches it to carry a View → provider message, and the host emits it to carry a provider → View message. A View → provider message is routed to the instance's single resolved provider — relayed to that client when the provider and renderer are different clients, or handled host-internally for a server-side provider. Targeting a particular renderer for a provider → View message when an instance has more than one renderer is not yet specified; it depends on the still-open render-targeting question and is out of scope here. ## Provider request family @@ -160,7 +162,7 @@ sequenceDiagram participant C as Client (provider + renderer) Note over C: declares ClientCapabilities.canvas
publishes canvasProviders - C->>H: dispatchAction session/activeClientsChanged (canvasProviders) + C->>H: dispatchAction session/activeClientSet (canvasProviders) H->>C: action session/canvasesChanged (registry) A->>H: open canvas "diff" @@ -192,11 +194,13 @@ sequenceDiagram `canvasReadResource` is modeled on MCP's `resources/read`; each `CanvasResourceContent` carries the resolved `uri`, an optional `mimeType`, and exactly one of `text` (UTF-8) or `blob` (base64). +The channel split within the family is deliberate: the three provider operations are session-scoped RPCs and travel on the session channel (`ahp-session:/`), while `canvasReadResource` resolves content bytes and so targets the instance's own channel (`ahp-canvas:/`). + ### Actions | Action | Direction | |---|---| -| `canvas/updated` | host → client (over the standard `action` envelope) | +| `canvas/updated` | client → host (a client-side provider) **or** host → client | | `canvas/closeRequested` | client → host | | `canvas/message` | client → host **or** host → client | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 15b0540a..2dc71180 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1884,7 +1884,7 @@ }, "CanvasUpdatedAction": { "type": "object", - "description": "The canvas instance's presentation state changed.\n\nSparse-merge semantics: each present field overwrites the corresponding\n{@link CanvasState} field, and an absent field preserves the current value.\nThere is no clear-to-absent via this action — that three-state distinction\ncannot survive JSON transport uniformly across languages, so a provider that\nneeds to reset a field re-publishes it, and a full reset arrives as a fresh\n{@link CanvasState} snapshot on (re)subscribe.", + "description": "The canvas instance's presentation state changed.\n\nEmitted by the server for a server-side provider, or dispatched by the client\nthat provides the instance to push its own presentation changes — mirroring\nhow `terminal/titleChanged` lets a client-owned terminal rename itself. This\nis the only path a client-side provider has to update the structured\n{@link CanvasState} (and the {@link OpenCanvasRef} fields mirrored from it)\nthat other subscribers render. The host stays the authoritative reducer: it\nSHOULD reject an update from a client that is not the instance's resolved\nprovider, then applies the merge and re-broadcasts to subscribers.\n\nSparse-merge semantics: each present field overwrites the corresponding\n{@link CanvasState} field, and an absent field preserves the current value.\nThere is no clear-to-absent via this action — that three-state distinction\ncannot survive JSON transport uniformly across languages, so a provider that\nneeds to reset a field re-publishes it, and a full reset arrives as a fresh\n{@link CanvasState} snapshot on (re)subscribe.", "properties": { "type": { "$ref": "#/$defs/ActionType.CanvasUpdated" diff --git a/schema/commands.schema.json b/schema/commands.schema.json index f7f5e57c..ed30d4d7 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -7921,7 +7921,7 @@ }, "CanvasUpdatedAction": { "type": "object", - "description": "The canvas instance's presentation state changed.\n\nSparse-merge semantics: each present field overwrites the corresponding\n{@link CanvasState} field, and an absent field preserves the current value.\nThere is no clear-to-absent via this action — that three-state distinction\ncannot survive JSON transport uniformly across languages, so a provider that\nneeds to reset a field re-publishes it, and a full reset arrives as a fresh\n{@link CanvasState} snapshot on (re)subscribe.", + "description": "The canvas instance's presentation state changed.\n\nEmitted by the server for a server-side provider, or dispatched by the client\nthat provides the instance to push its own presentation changes — mirroring\nhow `terminal/titleChanged` lets a client-owned terminal rename itself. This\nis the only path a client-side provider has to update the structured\n{@link CanvasState} (and the {@link OpenCanvasRef} fields mirrored from it)\nthat other subscribers render. The host stays the authoritative reducer: it\nSHOULD reject an update from a client that is not the instance's resolved\nprovider, then applies the merge and re-broadcasts to subscribers.\n\nSparse-merge semantics: each present field overwrites the corresponding\n{@link CanvasState} field, and an absent field preserves the current value.\nThere is no clear-to-absent via this action — that three-state distinction\ncannot survive JSON transport uniformly across languages, so a provider that\nneeds to reset a field re-publishes it, and a full reset arrives as a fresh\n{@link CanvasState} snapshot on (re)subscribe.", "properties": { "type": { "$ref": "#/$defs/ActionType.CanvasUpdated" diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index 832c56d5..edc9534d 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -339,13 +339,14 @@ export type CanvasAction = /** Union of canvas actions that clients may dispatch. */ export type ClientCanvasAction = + | CanvasUpdatedAction | CanvasCloseRequestedAction | CanvasMessageAction ; /** Union of canvas actions that only the server may produce. */ export type ServerCanvasAction = - | CanvasUpdatedAction + never ; // ─── Client-Dispatchable Map ───────────────────────────────────────────────── @@ -432,7 +433,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ResourceWatchChanged]: false, [ActionType.SessionCanvasesChanged]: false, [ActionType.SessionOpenCanvasesChanged]: false, - [ActionType.CanvasUpdated]: false, + [ActionType.CanvasUpdated]: true, [ActionType.CanvasCloseRequested]: true, [ActionType.CanvasMessage]: true, }; diff --git a/types/channels-canvas/actions.ts b/types/channels-canvas/actions.ts index 25e8ef03..e5bcbe38 100644 --- a/types/channels-canvas/actions.ts +++ b/types/channels-canvas/actions.ts @@ -12,6 +12,15 @@ import type { CanvasAvailability } from '../channels-session/state.js'; /** * The canvas instance's presentation state changed. * + * Emitted by the server for a server-side provider, or dispatched by the client + * that provides the instance to push its own presentation changes — mirroring + * how `terminal/titleChanged` lets a client-owned terminal rename itself. This + * is the only path a client-side provider has to update the structured + * {@link CanvasState} (and the {@link OpenCanvasRef} fields mirrored from it) + * that other subscribers render. The host stays the authoritative reducer: it + * SHOULD reject an update from a client that is not the instance's resolved + * provider, then applies the merge and re-broadcasts to subscribers. + * * Sparse-merge semantics: each present field overwrites the corresponding * {@link CanvasState} field, and an absent field preserves the current value. * There is no clear-to-absent via this action — that three-state distinction @@ -21,6 +30,7 @@ import type { CanvasAvailability } from '../channels-session/state.js'; * * @category Canvas Actions * @version 1 + * @clientDispatchable */ export interface CanvasUpdatedAction { type: ActionType.CanvasUpdated; From 7f11f60701bca91d8170135c7b1fc1bc43d30af9 Mon Sep 17 00:00:00 2001 From: Colby Williams Date: Wed, 1 Jul 2026 22:48:51 -0500 Subject: [PATCH 4/4] Address review: register canvas at current spec version; widen isClientDispatchable Copilot review follow-ups on the canvas PR: - Don't bump PROTOCOL_VERSION or invent a `## [0.6.0]` CHANGELOG heading in a feature PR. Per repo convention (AGENTS.md + the ea386ee precedent), new actions register at the *current* spec version and their bullets accumulate under `## [Unreleased]`; the version bump and `## [X.Y.Z]` rotation are a dedicated release chore. Revert PROTOCOL_VERSION 0.6.0 -> 0.5.1, drop 0.6.0 from SUPPORTED_PROTOCOL_VERSIONS, register the five canvas actions/session actions at 0.5.1, and fold the canvas bullet back under `## [Unreleased]`. Regenerate the per-client Version.generated.* and release-metadata.json. - Extend `isClientDispatchable` to accept `CanvasAction` and narrow to `ClientCanvasAction`, so consumers can validate the canvas channel's client-dispatchable actions (canvas/updated, canvas/closeRequested, canvas/message) without a cast. Add a canvas assertion to its test. The two mermaid comments (session/activeClientSet naming, canvas/updated direction) were already resolved in the prior review-response commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ---- clients/go/ahptypes/version.generated.go | 3 +-- clients/go/release-metadata.json | 1 - clients/kotlin/release-metadata.json | 1 - .../generated/Version.generated.kt | 3 +-- clients/rust/crates/ahp-types/src/version.rs | 4 ++-- clients/rust/release-metadata.json | 1 - .../Generated/Version.generated.swift | 3 +-- clients/swift/release-metadata.json | 1 - clients/typescript/release-metadata.json | 1 - types/common/reducer-helpers.ts | 4 +++- types/reducers.test.ts | 5 +++++ types/version/registry.ts | 13 ++++++------- 13 files changed, 19 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec33872..68e94429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,6 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). -## [0.6.0] — Unreleased - -Spec version: `0.6.0` - ### Added - Optional `intention` field on `chat/toolCallStart` and every `ToolCallState` diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 320619c1..8d270be4 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.6.0" +const ProtocolVersion = "0.5.1" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ - "0.6.0", "0.5.1", "0.5.0", } diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 37023344..c8eed75a 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,7 +2,6 @@ "client": "go", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.6.0", "0.5.1", "0.5.0" ] diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index f56205b0..d9e0f413 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,7 +2,6 @@ "client": "kotlin", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.6.0", "0.5.1", "0.5.0" ] 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 666c8aab..c1a4c3cf 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.6.0" +public const val PROTOCOL_VERSION: String = "0.5.1" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,7 +16,6 @@ public const val PROTOCOL_VERSION: String = "0.6.0" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( - "0.6.0", "0.5.1", "0.5.0", ) diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 8db522a4..5b04e5bc 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.6.0"; +pub const PROTOCOL_VERSION: &str = "0.5.1"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,4 +13,4 @@ pub const PROTOCOL_VERSION: &str = "0.6.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.6.0", "0.5.1", "0.5.0"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["0.5.1", "0.5.0"]; diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index dca82575..1cdb02a6 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,7 +2,6 @@ "client": "rust", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.6.0", "0.5.1", "0.5.0" ] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index b170a858..d4690313 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.6.0" +public let PROTOCOL_VERSION: String = "0.5.1" /// 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.6.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.6.0", "0.5.1", "0.5.0", ] diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index 328bb9fc..3765f19c 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,7 +2,6 @@ "client": "swift", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.6.0", "0.5.1", "0.5.0" ] diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index 8090197b..fd3c324d 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,7 +2,6 @@ "client": "typescript", "packageVersion": "0.5.0", "supportedProtocolVersions": [ - "0.6.0", "0.5.1", "0.5.0" ] diff --git a/types/common/reducer-helpers.ts b/types/common/reducer-helpers.ts index 7742f2af..acef55e0 100644 --- a/types/common/reducer-helpers.ts +++ b/types/common/reducer-helpers.ts @@ -16,6 +16,8 @@ import type { ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, + CanvasAction, + ClientCanvasAction, } from '../action-origin.generated.js'; import { IS_CLIENT_DISPATCHABLE } from '../action-origin.generated.js'; @@ -40,6 +42,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): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction { +export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | CanvasAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientCanvasAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/types/reducers.test.ts b/types/reducers.test.ts index e2bf929d..4f03ac4c 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -223,6 +223,11 @@ describe('isClientDispatchable', () => { const action = { type: ActionType.SessionReady, session: 'x' } as const; assert.equal(isClientDispatchable(action), false); }); + + it('accepts and narrows client-dispatchable canvas actions', () => { + const action = { type: ActionType.CanvasCloseRequested } as const; + assert.equal(isClientDispatchable(action), true); + }); }); // ─── Immutability Checks ───────────────────────────────────────────────────── diff --git a/types/version/registry.ts b/types/version/registry.ts index 68668a24..e621c706 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.6.0'; +export const PROTOCOL_VERSION = '0.5.1'; /** * Every protocol version a client built from this source tree is willing @@ -34,7 +34,6 @@ export const PROTOCOL_VERSION = '0.6.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '0.6.0', '0.5.1', '0.5.0', ]); @@ -152,11 +151,11 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.TerminalCommandExecuted]: '0.1.0', [ActionType.TerminalCommandFinished]: '0.1.0', [ActionType.ResourceWatchChanged]: '0.2.0', - [ActionType.SessionCanvasesChanged]: '0.6.0', - [ActionType.SessionOpenCanvasesChanged]: '0.6.0', - [ActionType.CanvasUpdated]: '0.6.0', - [ActionType.CanvasCloseRequested]: '0.6.0', - [ActionType.CanvasMessage]: '0.6.0', + [ActionType.SessionCanvasesChanged]: '0.5.1', + [ActionType.SessionOpenCanvasesChanged]: '0.5.1', + [ActionType.CanvasUpdated]: '0.5.1', + [ActionType.CanvasCloseRequested]: '0.5.1', + [ActionType.CanvasMessage]: '0.5.1', }; /**