diff --git a/CHANGELOG.md b/CHANGELOG.md index c0687a6a..7cd8dc18 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.10.0] — Unreleased + +Spec version: `0.10.0` + ## [0.9.0] — 2026-08-28 Spec version: `0.9.0` diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json index 128ac239..c745db9d 100644 --- a/clients/dotnet/release-metadata.json +++ b/clients/dotnet/release-metadata.json @@ -2,6 +2,7 @@ "client": "dotnet", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 01ee2af4..9c745bef 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -205,6 +205,20 @@ public enum ActionType AutomationRunPrimarySessionChanged, [WireValue("automationRun/cancelRequested")] AutomationRunCancelRequested, + [WireValue("session/canvasSet")] + SessionCanvasSet, + [WireValue("session/canvasRemoved")] + SessionCanvasRemoved, + [WireValue("canvas/availabilityChanged")] + CanvasAvailabilityChanged, + [WireValue("canvas/trustChanged")] + CanvasTrustChanged, + [WireValue("canvas/incarnationChanged")] + CanvasIncarnationChanged, + [WireValue("canvas/titleChanged")] + CanvasTitleChanged, + [WireValue("canvas/iconChanged")] + CanvasIconChanged, } // ─── Action Envelope ───────────────────────────────────────────────── @@ -2487,6 +2501,130 @@ public sealed record AutomationRunCancelRequestedAction public ActionType Type { get; init; } } +/// A canvas was admitted (opened) or its catalog entry changed. +/// +/// Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the +/// server dispatches this with the full entry to record a newly opened +/// canvas, or to republish it after a trust/availability/incarnation change +/// so subscribers following only the session channel stay in sync with +/// {@link CanvasState}. Never client-dispatchable: admission is through +/// `openCanvas` or host publication of a correlated, already-open native +/// instance under that command's admission rules. Both paths MUST use the +/// same singular identity-to-resource binding; repeated native observations +/// MUST NOT create a second entry. A stale/out-of-order delivery +/// (`canvas.revision` not strictly greater than the currently-recorded entry's +/// revision) MUST be rejected (no-op) rather than overwrite a newer entry with +/// older data. +public sealed record SessionCanvasSetAction +{ + public ActionType Type { get; init; } + + /// The canvas entry to add or update, matched by `resource`. + public required CanvasEntry Canvas { get; init; } +} + +/// A canvas was logically closed. +/// +/// Remove semantics keyed by `resource`: an unknown URI is a no-op. This +/// represents durable membership removal, not a client hiding a local +/// tab/view — see `closeCanvas`. +public sealed record SessionCanvasRemovedAction +{ + public ActionType Type { get; init; } + + /// Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + public required string Resource { get; init; } +} + +/// Replaces the canvas's live resolution state. +/// +/// Dispatched by the host on every availability transition, including +/// initial resolution after admission by `openCanvas` or a correlated native +/// open, provider restart, and endpoint failure/recovery. A client-local page +/// reload or transient presentation credential renewal alone does not require +/// this action or a revision change. +public sealed record CanvasAvailabilityChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasState.availability}. + public required CanvasAvailabilityState Availability { get; init; } + + /// The {@link CanvasState.revision} this action results in. The reducer + /// MUST reject (no-op) this action if `revision` is not strictly greater + /// than the canvas's current `revision` — this is how stale/out-of-order + /// deliveries are consistently rejected across every canvas action, not + /// just this one. + public long Revision { get; init; } +} + +/// Replaces the canvas's trust decision. +/// +/// Dispatched by the host whenever the execution-trust decision for this +/// canvas's declared actions changes (e.g. a pending decision resolves, or an +/// administrator revokes a previously trusted source). +public sealed record CanvasTrustChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasState.trust}. + public required CanvasTrustState Trust { get; init; } + + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public long Revision { get; init; } +} + +/// Records that the canvas's live endpoint was replaced by a fresh one for +/// the same logical instance (e.g. the owning provider restarted). +/// +/// Renewing transient presentation credentials for the same live endpoint is +/// not endpoint replacement and MUST NOT trigger this action. +/// +/// The host MUST dispatch {@link CanvasAvailabilityChangedAction} to +/// transition through `notLoaded`/`loading` around this change. Receivers +/// MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed +/// callbacks addressed to a superseded `incarnation` — because `incarnation` +/// is opaque (see {@link CanvasIdentity.incarnation}), that rejection is +/// driven by the accompanying `revision` bump here, not by comparing +/// `incarnation` values for order. +public sealed record CanvasIncarnationChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + public required string Incarnation { get; init; } + + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public long Revision { get; init; } +} + +/// Replaces the canvas's display title. +public sealed record CanvasTitleChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasState.title}. + public required string Title { get; init; } + + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public long Revision { get; init; } +} + +/// Replaces or removes the canvas's display icon. +/// +/// This is presentation metadata only. It does not replace the live endpoint, +/// change the canvas incarnation, or replay any canvas effect. +public sealed record CanvasIconChangedAction +{ + public ActionType Type { get; init; } + + /// New {@link CanvasState.icon}; `null` removes the current icon. + public required Icon? Icon { get; init; } + + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public required long Revision { get; init; } +} + // ─── Partial Summaries (action-discovered) ─────────────────────────── /// Partial equivalent of ChatSummary — every field is optional for delta updates. @@ -2666,6 +2804,13 @@ public StateActionConverter() ["automationRun/sessionRemoved"] = typeof(AutomationRunSessionRemovedAction), ["automationRun/primarySessionChanged"] = typeof(AutomationRunPrimarySessionChangedAction), ["automationRun/cancelRequested"] = typeof(AutomationRunCancelRequestedAction), + ["session/canvasSet"] = typeof(SessionCanvasSetAction), + ["session/canvasRemoved"] = typeof(SessionCanvasRemovedAction), + ["canvas/availabilityChanged"] = typeof(CanvasAvailabilityChangedAction), + ["canvas/trustChanged"] = typeof(CanvasTrustChangedAction), + ["canvas/incarnationChanged"] = typeof(CanvasIncarnationChangedAction), + ["canvas/titleChanged"] = typeof(CanvasTitleChangedAction), + ["canvas/iconChanged"] = typeof(CanvasIconChangedAction), }, allowUnknown: true) { diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 0ef02d8b..d0ef49e0 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -218,6 +218,24 @@ public sealed record InitializeResult /// host does not expose an automation catalogue or automation commands. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AutomationCapabilities? Automations { get; init; } + + /// Host/runtime-owned local-canvas support. Presence means the SERVER + /// currently has a working runtime able to serve `openCanvas` / + /// `invokeCanvasAction` for at least one qualifying (explicitly installed + /// and trust-eligible) extension/package source; absence means the host + /// has no available canvas runtime, and clients MUST treat every canvas as + /// {@link CanvasAvailabilityStatus.Unsupported} regardless of what + /// {@link ClientCapabilities.canvases} declared. + /// + /// **Protocol version support alone is not a runtime capability**: a host + /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// assumed to have a usable canvas runtime. This field — not the + /// negotiated `protocolVersion` — is the authoritative signal, and is + /// independent of any individual canvas's live availability + /// ({@link CanvasAvailabilityState}) or trust decision + /// ({@link CanvasTrustState}). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CanvasCapabilities? Canvases { get; init; } } /// Identifies a protocol implementation — the software (and build) on one end @@ -269,6 +287,25 @@ public sealed record ClientCapabilities /// App-bearing tool calls as ordinary MCP tool calls. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? McpApps { get; init; } + + /// Client can render local canvases: `listCanvasTypes`, `openCanvas`, + /// subscribe to the resulting `ahp-canvas:` channel, and drive + /// `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + /// `closeCanvas`. + /// + /// Hosts SHOULD NOT offer canvas admission to a client that omits this + /// capability; such a client MUST be treated as if every canvas were + /// {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + /// anything about server/runtime execution trust — see + /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. + /// + /// This declares only the CLIENT's rendering capability. Protocol version + /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// actually has a working canvas runtime — see + /// {@link InitializeResult.canvases}, the server-side counterpart, which a + /// client MUST also check before treating canvases as usable. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Canvases { get; init; } } /// Automation features supported by this host authority. @@ -302,6 +339,13 @@ public sealed record AutomationCapabilities public long? RunHistoryLimit { get; init; } } +/// Local-canvas runtime features supported by this host authority. The empty +/// object means "supported" — see {@link InitializeResult.canvases} for what +/// presence/absence of this field itself means. +public sealed record CanvasCapabilities +{ +} + /// Presence capability for {@link AutomationCreateRequestedAction | /// `automation/createRequested`}. /// @@ -1673,6 +1717,359 @@ public sealed record FetchAutomationRunsResult { } +/// Discovers canvas TYPES currently available to open for one exact backing +/// chat. +/// +/// This is a **pure read/browse** operation: it MUST NOT execute or start a +/// provider, or open, materialize, or otherwise admit any canvas. See +/// `openCanvas` for the admission rules, including publication of an +/// already-open native instance. This catalogue is unrelated to +/// {@link SessionState.canvases}, which reflects durable membership of +/// already-opened canvas INSTANCES, not the set of canvas TYPES a +/// host/extension could open; do not confuse the two. +public sealed record ListCanvasTypesParams +{ + /// The exact backing chat to discover available canvas types for. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Maximum number of entries to return in this page. The server SHOULD respect + /// this bound but MAY return fewer entries and MAY impose its own upper cap. + /// Omit to let the server choose the page size. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Limit { get; init; } + + /// Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + /// Omit to fetch the first page. Cursors are server-defined and MUST be treated + /// as opaque — do not parse, modify, or persist them across connections. An + /// unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cursor { get; init; } +} + +/// Available canvas types for the requested chat. +public sealed record ListCanvasTypesResult +{ + /// Opaque cursor for the next page. Present when more entries exist beyond the + /// returned page; absent signals the end of the collection. Pass it back as + /// {@link PaginatedParams.cursor} to fetch the following page. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? NextCursor { get; init; } + + /// Discovered canvas type declarations. + public required List Types { get; init; } +} + +/// Explicitly opens (admits) a canvas, associating it with the owning chat +/// given by `identity.chat` at the moment of the call — never with whichever +/// chat later happens to have focus. +/// +/// Canvas membership requires explicit admission. A client admits a canvas +/// by calling `openCanvas`, a read-write operation. `listCanvasTypes`, +/// `subscribe`, and `resolveCanvasSource` MUST NOT admit a canvas or execute +/// or start its provider. +/// +/// A host MAY also publish membership after observing an instance already +/// opened by the owning native runtime. Before publication, the host MUST +/// correlate the observation to the actual backing chat, canonical source, +/// canvas type, and native instance, and enforce applicable +/// execution-admission policy. Uncorrelated or conflicting observations MUST +/// be rejected rather than assigned to the focused chat or a guessed source. +/// Observation does not grant execution trust: it MUST NOT convert `pending` +/// or `blocked` trust to `trusted`; trust and availability remain independent. +/// +/// Native publication follows the same singular identity-to-resource binding +/// and authoritative state rules as client-originated admission. The host +/// MUST NOT manufacture a client `openCanvas` request or invoke the provider's +/// open handler again merely to publish an already-open instance. Repeated +/// observations MUST NOT duplicate membership; this does not suppress the +/// actual effects of a genuinely new native open. Hosts MUST preserve the +/// native instance-ID namespace, including session-wide IDs across providers +/// where the owning runtime requires them, rather than hide collisions with +/// an invented provider namespace. Client `requestId` semantics are unchanged. +/// +/// Once admitted by either path, clients read and follow live state by +/// `subscribe`-ing to `canvas.resource`, and resolve the current live endpoint +/// via `resolveCanvasSource`; neither read opens, resumes, or restarts anything. +/// +/// **Logical identity is always singular.** The same {@link CanvasIdentityKey} +/// (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same +/// `canvas` resource URI and the same {@link SessionState.canvases} catalog +/// entry, no matter how many times `openCanvas` is called or a native open is +/// observed for it. The server MUST reuse that existing entry's `resource` +/// rather than mint a second one. A client-supplied `canvas` URI is honored +/// only on the call that first establishes the identity; on a later call for +/// an already-recorded identity the server MUST ignore the supplied `canvas` +/// value and return the existing resource instead. +/// +/// **Idempotency is scoped to `requestId`, not identity.** Retrying with the +/// exact same `requestId` and byte-for-byte identical params from the same +/// authenticated connection MUST return the original result without +/// repeating any side effect, within a bounded live window (the server is +/// not required to remember it forever). Reusing the same `requestId` with +/// any different parameter value MUST be rejected with `Conflict` +/// (`-32011`) — mint a new `requestId` for a new logical call. A genuinely +/// NEW `requestId` for an already-open identity MAY be effectful (e.g. +/// updating `title`/`icon`, or causing the provider to re-run its own +/// open-time initialization with new `input`) — this mirrors the pinned +/// SDK's own repeated-open behavior and does not create a second logical +/// identity. There is no exactly-once-across-crash guarantee: a lost reply +/// is indeterminate, and clients MUST NOT automatically replay `openCanvas` +/// — reconnect and read `SessionState.canvases` / `resolveCanvasSource` +/// instead to determine the actual outcome. +public sealed record OpenCanvasParams +{ + /// Session URI that will list the opened canvas in `SessionState.canvases`. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Canvas URI (client-chosen, e.g. `ahp-canvas:/<uuid>`); honored only when this call first establishes `identity` — see above. + public required string Canvas { get; init; } + + /// Logical identity to open or re-admit. + public required CanvasIdentityKey Identity { get; init; } + + /// Initial (or updated, on a later effectful call) display title. + public required string Title { get; init; } + + /// Initial (or updated) display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; init; } + + /// Bounded JSON input for this open call (e.g. seed parameters the + /// provider uses to initialize the canvas), opaque to the protocol. See + /// {@link CanvasTypeDeclaration.openInputSchema} / + /// `openInputSchemaRef` for the expected shape. The JSON-serialized value + /// MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Input { get; init; } + + /// Durable client-generated idempotency key bounding retry deduplication + /// for this call within a live window; see the idempotency rules above. + /// MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } +} + +/// Result identifying the existing or newly opened canvas. +public sealed record OpenCanvasResult +{ + /// The catalog entry for the opened (or already-open) canvas. + public required CanvasEntry Canvas { get; init; } +} + +/// Reads a canvas's current live-resolution state and, when currently live, +/// a transient endpoint presentation. +/// +/// This is read-only with respect to membership, provider execution/lifecycle, +/// and durable canvas state. It MUST NOT admit a canvas or create, resume, +/// reopen, or restart a provider, including on unavailable or unauthorized +/// requests. Authorization failure MUST NOT expose `source`. +/// +/// For an already-live endpoint, authorized resolution MAY issue or refresh +/// transient presentation credentials while constructing the response. Two +/// resolutions of the same live incarnation MAY therefore return different +/// URLs. Credential refresh alone MUST NOT rerun provider open, change the +/// incarnation, or require a canvas state revision. If the live state changes +/// concurrently during resolution, the response MUST report that state's +/// current availability, revision, and incarnation; any returned `source` +/// MUST correspond to that reported state. +/// +/// Clients MUST NOT replace a newer attachment with a superseded resolution +/// response, even when credential renewal leaves `revision` and `incarnation` +/// unchanged. These state guards do not order same-state credential refreshes. +/// +/// If the canvas does not currently have a live endpoint, `source` is absent +/// and `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`). +/// Call `restartCanvasProvider` (an explicitly effectful operation) to +/// attempt recovery instead; calling `resolveCanvasSource` again only +/// retries reading the current state without restarting anything. +/// +/// A client-local page reload needs no provider restart or new effectful +/// command. Before reload or reattachment, the client SHOULD resolve a fresh +/// presentation unless the existing credential is known to remain valid and +/// reusable. An absent expiry hint does not imply indefinite validity or +/// reusability. Presentation URLs and credentials MUST NOT enter durable +/// membership, editor restoration data, or routine logs. +public sealed record ResolveCanvasSourceParams +{ + /// The canvas URI (an already-opened canvas's `resource`). + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } +} + +/// The canvas's current live-resolution state as of this read. +public sealed record ResolveCanvasSourceResult +{ + /// Current {@link CanvasEntry.availability}. + public CanvasAvailabilityStatus Availability { get; init; } + + /// Current {@link CanvasIdentity.incarnation}. + public required string Incarnation { get; init; } + + /// Current {@link CanvasEntry.revision}. + public long Revision { get; init; } + + /// Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CanvasSourcePresentation? Source { get; init; } +} + +/// Invokes one of a canvas's currently declared actions exactly once. +/// +/// The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's +/// current trust is not `trusted`, and with `NotFound` (`-32008`) if +/// `actionId` does not match a currently declared action. `incarnation` is +/// REQUIRED — omitting stale-generation protection on an effectful call is +/// not allowed. If it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` +/// (`-32011`) rather than route the call to a superseded endpoint. +/// +/// The result is the provider's raw reply and is never persisted into +/// `CanvasState` — large or provider-specific payloads stay off the durable +/// state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be +/// represented out of band instead of being returned inline. Any resulting +/// state changes (e.g. a subsequent availability transition) flow back +/// separately through the normal `canvas/*` action stream on the canvas's +/// own channel. +/// +/// A lost reply (e.g. a dropped connection after the provider already ran +/// the handler) is **indeterminate**: clients MUST NOT automatically replay +/// `invokeCanvasAction` on reconnect. Instead, reconnect and read the +/// canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and +/// decide from observed `revision`/`incarnation` and any provider-visible +/// side effect whether to surface the ambiguity to the user, rather than +/// assuming success or failure. +public sealed record InvokeCanvasActionParams +{ + /// The canvas URI. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + public required string ActionId { get; init; } + + /// Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + /// if any. The JSON-serialized value MUST NOT exceed + /// `CANVAS_INPUT_MAX_LENGTH`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Input { get; init; } + + /// Expected {@link CanvasIdentity.incarnation}. Required — see above. The + /// server MUST reject the call with `Conflict` if the canvas's live + /// endpoint has since been superseded, rather than deliver the call to it. + public required string Incarnation { get; init; } + + /// Durable client-generated idempotency key bounding retry + /// deduplication for this invocation within a live window. The server is + /// not required to guarantee exactly-once execution across a crash. MUST + /// NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } +} + +/// Result of invoking a declared canvas action. +public sealed record InvokeCanvasActionResult +{ + /// The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + public JsonElement Result { get; init; } +} + +/// Explicitly restarts the provider/chat-scoped runtime backing this canvas: +/// retires the current live endpoint and establishes a fresh one for the +/// same logical instance. +/// +/// This is the **only** operation that intentionally causes an +/// {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` +/// (read-only source resolution / client-local page reload) MUST NEVER +/// trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} +/// (transitioning through `notLoaded`/`loading`) and then +/// {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart +/// never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or +/// restore any prior in-flight effect. +/// +/// `incarnation` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, so a caller cannot restart a +/// generation it never observed (e.g. after racing a concurrent restart). A +/// lost reply is indeterminate; clients MUST NOT automatically replay this +/// command — reconnect and compare the canvas's current `incarnation` +/// instead. +public sealed record RestartCanvasProviderParams +{ + /// The canvas URI. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } + + /// Expected current {@link CanvasIdentity.incarnation}; required — see above. + public required string Incarnation { get; init; } +} + +/// Logically closes a canvas: removes its durable membership from +/// `SessionState.canvases` and disposes matching views. +/// +/// This is distinct from a client merely hiding a local tab or view, which is +/// presentation-only and MUST NOT dispatch this command. There is no +/// advertised model tool for this operation — it is invoked only by +/// UI/RPC callers. +/// +/// `revision` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasEntry.revision}, so a caller cannot close membership state it +/// never actually observed. If no matching entry exists (e.g. already +/// closed), the server MUST treat this as a successful no-op rather than an +/// error — the `revision` precondition only applies when an entry still +/// exists. A lost reply is indeterminate; clients MUST NOT automatically +/// replay this command — reconnect and check `SessionState.canvases` +/// instead. +public sealed record CloseCanvasParams +{ + /// The canvas URI. + public required string Channel { get; init; } + + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; init; } + + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public required string RequestId { get; init; } + + /// Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + public long Revision { get; init; } +} + // ─── ReconnectResult Union ──────────────────────────────────────────── /// ReconnectResult is the result of the `reconnect` command. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 9b164021..32653f70 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -72,6 +72,36 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(AutomationTriggerEventDefinition))] [JsonSerializable(typeof(AutomationTriggerKind))] [JsonSerializable(typeof(AutomationUpdateRequestedAction))] +[JsonSerializable(typeof(CanvasActionDeclaration))] +[JsonSerializable(typeof(CanvasAvailabilityChangedAction))] +[JsonSerializable(typeof(CanvasAvailabilityState))] +[JsonSerializable(typeof(CanvasAvailabilityStatus))] +[JsonSerializable(typeof(CanvasBlockedTrustState))] +[JsonSerializable(typeof(CanvasCapabilities))] +[JsonSerializable(typeof(CanvasEmptyAvailabilityState))] +[JsonSerializable(typeof(CanvasEntry))] +[JsonSerializable(typeof(CanvasExtensionSource))] +[JsonSerializable(typeof(CanvasFailedAvailabilityState))] +[JsonSerializable(typeof(CanvasIconChangedAction))] +[JsonSerializable(typeof(CanvasIdentity))] +[JsonSerializable(typeof(CanvasIdentityKey))] +[JsonSerializable(typeof(CanvasIncarnationChangedAction))] +[JsonSerializable(typeof(CanvasLoadingAvailabilityState))] +[JsonSerializable(typeof(CanvasNotLoadedAvailabilityState))] +[JsonSerializable(typeof(CanvasPackageSource))] +[JsonSerializable(typeof(CanvasPendingTrustState))] +[JsonSerializable(typeof(CanvasReadyAvailabilityState))] +[JsonSerializable(typeof(CanvasSource))] +[JsonSerializable(typeof(CanvasSourceKind))] +[JsonSerializable(typeof(CanvasSourcePresentation))] +[JsonSerializable(typeof(CanvasState))] +[JsonSerializable(typeof(CanvasTitleChangedAction))] +[JsonSerializable(typeof(CanvasTrustChangedAction))] +[JsonSerializable(typeof(CanvasTrustedState))] +[JsonSerializable(typeof(CanvasTrustState))] +[JsonSerializable(typeof(CanvasTrustStatus))] +[JsonSerializable(typeof(CanvasTypeDeclaration))] +[JsonSerializable(typeof(CanvasUnsupportedAvailabilityState))] [JsonSerializable(typeof(Changeset))] [JsonSerializable(typeof(ChangesetCapabilities))] [JsonSerializable(typeof(ChangesetClearedAction))] @@ -158,6 +188,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ChildCustomization))] [JsonSerializable(typeof(ClientCapabilities))] [JsonSerializable(typeof(ClientPluginCustomization))] +[JsonSerializable(typeof(CloseCanvasParams))] [JsonSerializable(typeof(CompletionItem))] [JsonSerializable(typeof(CompletionItemKind))] [JsonSerializable(typeof(CompletionsParams))] @@ -207,6 +238,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(InitializeParams))] [JsonSerializable(typeof(InitializeResult))] [JsonSerializable(typeof(InputRequestResponsePart))] +[JsonSerializable(typeof(InvokeCanvasActionParams))] +[JsonSerializable(typeof(InvokeCanvasActionResult))] [JsonSerializable(typeof(InvokeChangesetOperationParams))] [JsonSerializable(typeof(InvokeChangesetOperationResult))] [JsonSerializable(typeof(JsonRpcErrorObject))] @@ -217,6 +250,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(JsonRpcSuccessResponse))] [JsonSerializable(typeof(ListAutomationTriggerDefinitionsParams))] [JsonSerializable(typeof(ListAutomationTriggerDefinitionsResult))] +[JsonSerializable(typeof(ListCanvasTypesParams))] +[JsonSerializable(typeof(ListCanvasTypesResult))] [JsonSerializable(typeof(ListSessionsParams))] [JsonSerializable(typeof(ListSessionsResult))] [JsonSerializable(typeof(MarkdownResponsePart))] @@ -244,6 +279,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ModelSelection))] [JsonSerializable(typeof(MultipleChatsCapability))] [JsonSerializable(typeof(MultipleWorkingDirectoriesCapability))] +[JsonSerializable(typeof(OpenCanvasParams))] +[JsonSerializable(typeof(OpenCanvasResult))] [JsonSerializable(typeof(OtlpExportLogsParams))] [JsonSerializable(typeof(OtlpExportMetricsParams))] [JsonSerializable(typeof(OtlpExportTracesParams))] @@ -264,6 +301,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ReconnectResult))] [JsonSerializable(typeof(ReconnectResultType))] [JsonSerializable(typeof(ReconnectSnapshotResult))] +[JsonSerializable(typeof(ResolveCanvasSourceParams))] +[JsonSerializable(typeof(ResolveCanvasSourceResult))] [JsonSerializable(typeof(ResolveSessionConfigParams))] [JsonSerializable(typeof(ResolveSessionConfigResult))] [JsonSerializable(typeof(ResourceChange))] @@ -293,6 +332,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ResourceWriteResult))] [JsonSerializable(typeof(ResponsePart))] [JsonSerializable(typeof(ResponsePartKind))] +[JsonSerializable(typeof(RestartCanvasProviderParams))] [JsonSerializable(typeof(RootActiveSessionsChangedAction))] [JsonSerializable(typeof(RootAgentsChangedAction))] [JsonSerializable(typeof(RootConfigChangedAction))] @@ -307,6 +347,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(SessionActiveClientSetAction))] [JsonSerializable(typeof(SessionActivityChangedAction))] [JsonSerializable(typeof(SessionAddedParams))] +[JsonSerializable(typeof(SessionCanvasRemovedAction))] +[JsonSerializable(typeof(SessionCanvasSetAction))] [JsonSerializable(typeof(SessionChangesetsChangedAction))] [JsonSerializable(typeof(SessionChatAddedAction))] [JsonSerializable(typeof(SessionChatInputRequest))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 5ead9d4c..d6f759ac 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -665,6 +665,73 @@ public enum AutomationRunOriginKind Trigger, } +/// Discriminant for {@link CanvasSource} — what kind of package originates a +/// canvas type. +[JsonConverter(typeof(WireEnumConverter))] +public enum CanvasSourceKind +{ + /// An explicitly installed host extension. + [WireValue("extension")] + Extension, + /// An explicitly installed package (not a host extension). + [WireValue("package")] + Package, +} + +/// Discriminant for {@link CanvasTrustState} — whether the host currently +/// permits this canvas's declared actions to execute. +/// +/// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +/// a canvas may be perfectly capable of rendering while blocked from +/// executing actions, and vice versa. Trust decisions are host/runtime +/// authority, not something this protocol grants. +[JsonConverter(typeof(WireEnumConverter))] +public enum CanvasTrustStatus +{ + /// Declared actions may be invoked. + [WireValue("trusted")] + Trusted, + /// A trust decision has not yet been made (e.g. first use of a new/changed source). + [WireValue("pending")] + Pending, + /// The host has denied execution; declared actions MUST NOT be invoked. + [WireValue("blocked")] + Blocked, +} + +/// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +/// live resolution state, independent of its durable +/// {@link CanvasEntry | membership} in a session's catalog. +/// +/// An empty catalog membership list is not itself a close, and a canvas may +/// remain a recorded member while its live availability cycles through these +/// states any number of times (e.g. across provider restarts). +[JsonConverter(typeof(WireEnumConverter))] +public enum CanvasAvailabilityStatus +{ + /// The connected client or host does not support this canvas type (e.g. + /// the client omitted the `canvases` capability, or no local runtime can + /// render this `canvasType`). Distinct from `blocked` trust, which is a + /// policy decision rather than a capability gap. + [WireValue("unsupported")] + Unsupported, + /// Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + [WireValue("notLoaded")] + NotLoaded, + /// Currently resolving or (re)connecting to a live endpoint. + [WireValue("loading")] + Loading, + /// Live and reachable, but the provider has not yet produced content to render. + [WireValue("empty")] + Empty, + /// Live, reachable, and has declared its current actions. + [WireValue("ready")] + Ready, + /// The live endpoint failed to resolve, or resolution otherwise failed. + [WireValue("failed")] + Failed, +} + // ─── Classes ────────────────────────────────────────────────────────── /// An optionally-sized icon that can be displayed in a user interface. @@ -1620,6 +1687,17 @@ public sealed class SessionState [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Changesets { get; set; } + /// Catalog of canvases opened for chats in this session. Presence is + /// durable logical membership, admitted via `openCanvas` or host publication + /// of a correlated, already-open native instance under that command's + /// admission rules. Membership is never implied by discovery, subscription, + /// source resolution, a chat's existence, or a client's earlier focus. + /// Each entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the + /// exact backing chat; a canvas never migrates to a different chat. See + /// {@link CanvasEntry} for the full membership/availability/trust model. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Canvases { get; set; } + /// Outstanding input the session is blocked on, aggregated across every chat /// so a client can discover and answer it from the session channel alone, /// without subscribing to individual chats. @@ -5457,6 +5535,376 @@ public sealed class AutomationRunState public Dictionary? Meta { get; set; } } +/// A canvas type provided by an installed host extension. +/// +/// `extensionId` is the identity-bearing field for comparison purposes (see +/// {@link CanvasIdentityKey}). `version` is display/informational metadata +/// only — it MUST NOT be treated as identity-bearing (two `CanvasSource` +/// values that differ only in `version` are the same source). +public sealed record CanvasExtensionSource +{ + /// Stable extension identifier (host-defined format, e.g. `publisher.name`). + /// MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string ExtensionId { get; init; } + + /// Installed extension version, when known. Metadata only — not identity-bearing. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } +} + +/// A canvas type provided by an installed package that is not a host +/// extension (e.g. a workspace-declared runtime package). +/// +/// `sourceId` — not `packageName` — is the identity-bearing field: the same +/// declared package name MAY be installed in more than one scope (e.g. a +/// workspace-local copy and a globally-installed copy, or two different +/// registries), and each such installation is a distinct source with its own +/// `sourceId`. `packageName` and `version` are display/informational metadata +/// only and MUST NOT be treated as identity-bearing. +public sealed record CanvasPackageSource +{ + /// Stable, host- or package-manager-assigned unique identifier for this + /// specific installed package instance/scope (opaque format). This is the + /// identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string SourceId { get; init; } + + /// Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + public required string PackageName { get; init; } + + /// Installed package version, when known. Metadata only — not identity-bearing. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } +} + +/// The logical identity of a canvas, excluding the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +/// +/// Two canvases are the same logical canvas iff `chat`, `canvasType`, +/// `instanceId`, and `source`'s **identity-bearing** fields are all equal: +/// `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` +/// plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and +/// `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into +/// this comparison. Clients MUST NOT treat +/// {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — +/// it is only unique within the scope of `(chat, source, canvasType)`. +/// +/// This logical tuple does not widen the owning runtime's native instance-ID +/// namespace. A runtime may require session-wide native IDs across providers; +/// hosts MUST preserve that constraint rather than hide native collisions +/// with an invented provider namespace. +public sealed record CanvasIdentityKey +{ + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public required string Chat { get; init; } + + /// The extension or package that declares this canvas's type. + public required CanvasSource Source { get; init; } + + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string CanvasType { get; init; } + + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string InstanceId { get; init; } +} + +/// Full identity of a canvas, including the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +public sealed record CanvasIdentity +{ + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public required string Chat { get; init; } + + /// The extension or package that declares this canvas's type. + public required CanvasSource Source { get; init; } + + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string CanvasType { get; init; } + + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string InstanceId { get; init; } + + /// Opaque, host-generated token identifying the current generation of this + /// canvas's live endpoint. The host mints a fresh token whenever a provider + /// restart retires the previous live endpoint and establishes a new one for + /// the same logical instance (see {@link CanvasIncarnationChangedAction | + /// `canvas/incarnationChanged`}); it is not changed by a plain page reload + /// or transient presentation credential renewal for the same still-live + /// endpoint. + /// + /// `incarnation` is **opaque**: clients and hosts MUST compare it only for + /// equality, never parse it, sort it, or perform arithmetic on it (e.g. it + /// is not guaranteed to be numeric or monotonically increasing). The host + /// MUST NOT reuse a token for this logical identity once it has been + /// superseded, including across a host/process restart — if the host + /// cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + /// or timestamp-derived) that make accidental reuse practically + /// impossible, rather than a small resettable counter. + /// + /// Clients and hosts use `incarnation` to reject stale callbacks and + /// in-flight effects addressed to a superseded endpoint. + public required string Incarnation { get; init; } +} + +public sealed record CanvasTrustedState +{ +} + +public sealed record CanvasPendingTrustState +{ +} + +public sealed record CanvasBlockedTrustState +{ + /// Optional human-readable reason surfaced to the user. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Reason { get; init; } +} + +/// One action a canvas declares it can perform, invoked via +/// `invokeCanvasAction`. +/// +/// Declarations are carried only on the full {@link CanvasState}, loaded when +/// a client subscribes — never duplicated into the lightweight +/// {@link CanvasEntry} catalog entry, keeping session summaries small. +public sealed record CanvasActionDeclaration +{ + /// Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + public required string Id { get; init; } + + /// Human-readable display name. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// Description of what invoking the action does. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Inline JSON Schema for the expected `input`, when small enough to embed + /// (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + /// checked by {@link isCanvasSchemaWithinLimits}). Optional because some + /// declared actions take no input. Mutually exclusive with + /// `inputSchemaRef` — a declaration MUST supply at most one of the two. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? InputSchema { get; init; } + + /// Bounded out-of-band reference to a larger JSON Schema, used instead of + /// `inputSchema` when the schema would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. AHP does not mandate a specific resolution mechanism for this + /// URI (e.g. a host MAY make it `resourceRead`-able). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InputSchemaRef { get; init; } +} + +public sealed record CanvasUnsupportedAvailabilityState +{ +} + +public sealed record CanvasNotLoadedAvailabilityState +{ +} + +public sealed record CanvasLoadingAvailabilityState +{ +} + +public sealed record CanvasEmptyAvailabilityState +{ +} + +public sealed record CanvasReadyAvailabilityState +{ + /// Actions currently declared by the live provider (full replacement each time this state is produced). + public required List Actions { get; init; } +} + +public sealed record CanvasFailedAvailabilityState +{ + /// Stable machine-readable and human-readable failure information. + public required ErrorInfo Error { get; init; } +} + +/// Lightweight catalog entry for a canvas, carried in +/// {@link SessionState.canvases | `SessionState.canvases`}. Presence +/// represents durable **logical membership** — it is unaffected by the live +/// {@link CanvasEntry.availability | `availability`} cycling through +/// `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. +/// +/// Membership is admitted by `openCanvas` or by host publication of a +/// correlated, already-open native instance under that command's admission +/// rules, never by discovery, subscription, or source resolution. +/// +/// The full state, including declared actions, lives in {@link CanvasState}, +/// loaded when a client subscribes to {@link CanvasEntry.resource}. +public sealed class CanvasEntry +{ + /// Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + public required string Resource { get; set; } + + /// Full identity, including current incarnation. + public required CanvasIdentity Identity { get; set; } + + /// Human-readable display title. + public required string Title { get; set; } + + /// Optional display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; set; } + + /// Current trust decision matching {@link CanvasState.trust}. + public required CanvasTrustState Trust { get; set; } + + /// Current availability status matching {@link CanvasState.availability}'s discriminant. + public CanvasAvailabilityStatus Availability { get; set; } + + /// Monotonically increasing counter bumped on every change to this + /// canvas's state (trust, availability, or incarnation). Clients MAY use it + /// to detect and reject stale reads without a full deep comparison. + /// Transient presentation credential renewal alone does not require a + /// revision change. + public long Revision { get; set; } + + /// Opaque host-defined summary metadata. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// Full state for a single canvas, loaded when a client subscribes to the +/// canvas's URI. +/// +/// `CanvasState` **denormalizes** every {@link CanvasEntry} field directly +/// onto itself, replacing `availability`'s lightweight status with the full +/// {@link CanvasAvailabilityState} (including declared actions or failure +/// detail). Producers MUST keep the two representations consistent: any +/// change to the inlined fields SHOULD also be announced on the owning +/// session via {@link SessionCanvasSetAction | `session/canvasSet`}. +public sealed class CanvasState +{ + /// URI of this canvas channel. + public required string Resource { get; set; } + + /// Full identity, including current incarnation. + public required CanvasIdentity Identity { get; set; } + + /// Human-readable display title. + public required string Title { get; set; } + + /// Optional display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; set; } + + /// Current trust decision. + public required CanvasTrustState Trust { get; set; } + + /// Current live resolution state. + public required CanvasAvailabilityState Availability { get; set; } + + /// Matches {@link CanvasEntry.revision}. + public long Revision { get; set; } + + /// Opaque host-defined metadata. + [JsonPropertyName("_meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} + +/// A canvas type an installed extension or package currently makes available +/// to open for a chat, as returned by `listCanvasTypes`. +/// +/// `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is +/// unrelated to {@link CanvasEntry}, which represents durable membership of +/// an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the +/// catalogue (via `listCanvasTypes`) MUST NOT execute or start a provider, +/// or open, materialize, or admit a canvas. Membership requires `openCanvas` +/// or host publication of a correlated, already-open native instance under +/// that command's admission rules. +public sealed record CanvasTypeDeclaration +{ + /// The extension or package that declares this canvas type. + public required CanvasSource Source { get; init; } + + /// Provider-declared canvas type (host/provider-defined format), passed as + /// {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public required string CanvasType { get; init; } + + /// Human-readable display name for a canvas-type picker. + public required string Title { get; init; } + + /// Description of what this canvas type does. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// Optional display icon. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Icon? Icon { get; init; } + + /// Inline JSON Schema describing the `openCanvas` `input` this type + /// expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + /// / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + /// `openInputSchemaRef`. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? OpenInputSchema { get; init; } + + /// Bounded out-of-band reference to a larger open-input JSON Schema, used + /// instead of `openInputSchema` when it would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OpenInputSchemaRef { get; init; } + + /// Advisory, statically-known preview of actions this canvas type + /// typically declares once opened (bounded to + /// {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + /// the actual invocable actions for an opened instance are always + /// {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + /// depend on live provider configuration) and MUST be used instead of this + /// preview once the canvas is open. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? DeclaredActions { get; init; } +} + +/// Transient, renderer-neutral presentation of a canvas's current live +/// endpoint, returned by `resolveCanvasSource`. +/// +/// This is a plain URL, not any renderer- or process-model-specific handle +/// (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview +/// panel reference) — how a client actually presents it (a VS Code Webview, +/// the Integrated Browser, or otherwise) is entirely a client/host +/// implementation detail outside this protocol. +public sealed record CanvasSourcePresentation +{ + /// Ephemeral URL to the canvas's current live endpoint. Transient: MUST + /// NOT be persisted (including durable canvas/session state or editor + /// restoration data), written to routine logs, or treated as a stable + /// identity. A host MAY embed + /// short-lived, single-use credentials in it; such credentials are never + /// durable authority. Renewed credentials MAY produce a different URL for + /// the same incarnation and revision. Reuse is safe only while the + /// credential is known to remain valid and reusable. + public required string Url { get; init; } + + /// Advisory expiry hint for `url` (and any embedded credential), when + /// known. Omission does not imply indefinite validity or reusability, and + /// an unexpired credential may still be single-use. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ExpiresAt { get; init; } +} + // ─── Discriminated Unions ───────────────────────────────────────────── /// A single explicit customization enablement decision. @@ -6135,6 +6583,92 @@ public AutomationRunLifecycleConverter() } } +/// CanvasSource identifies the explicitly installed extension or package that declares a canvas type. +[JsonConverter(typeof(CanvasSourceConverter))] +public sealed class CanvasSource : AhpUnion +{ + /// Creates an empty CanvasSource (no active variant). + public CanvasSource() { } + + /// Creates a CanvasSource wrapping the given variant value. + public CanvasSource(object? value) : base(value) { } +} + +/// System.Text.Json converter for the CanvasSource discriminated union. +internal sealed class CanvasSourceConverter : UnionConverter +{ + public CanvasSourceConverter() + : base( + discriminator: "kind", + variants: new Dictionary + { + ["extension"] = typeof(CanvasExtensionSource), + ["package"] = typeof(CanvasPackageSource), + }, + allowUnknown: false) + { + } +} + +/// CanvasTrustState is the current trust decision governing whether a canvas's declared actions may execute. +[JsonConverter(typeof(CanvasTrustStateConverter))] +public sealed class CanvasTrustState : AhpUnion +{ + /// Creates an empty CanvasTrustState (no active variant). + public CanvasTrustState() { } + + /// Creates a CanvasTrustState wrapping the given variant value. + public CanvasTrustState(object? value) : base(value) { } +} + +/// System.Text.Json converter for the CanvasTrustState discriminated union. +internal sealed class CanvasTrustStateConverter : UnionConverter +{ + public CanvasTrustStateConverter() + : base( + discriminator: "status", + variants: new Dictionary + { + ["trusted"] = typeof(CanvasTrustedState), + ["pending"] = typeof(CanvasPendingTrustState), + ["blocked"] = typeof(CanvasBlockedTrustState), + }, + allowUnknown: false) + { + } +} + +/// CanvasAvailabilityState is the current live resolution state of a canvas. +[JsonConverter(typeof(CanvasAvailabilityStateConverter))] +public sealed class CanvasAvailabilityState : AhpUnion +{ + /// Creates an empty CanvasAvailabilityState (no active variant). + public CanvasAvailabilityState() { } + + /// Creates a CanvasAvailabilityState wrapping the given variant value. + public CanvasAvailabilityState(object? value) : base(value) { } +} + +/// System.Text.Json converter for the CanvasAvailabilityState discriminated union. +internal sealed class CanvasAvailabilityStateConverter : UnionConverter +{ + public CanvasAvailabilityStateConverter() + : base( + discriminator: "status", + variants: new Dictionary + { + ["unsupported"] = typeof(CanvasUnsupportedAvailabilityState), + ["notLoaded"] = typeof(CanvasNotLoadedAvailabilityState), + ["loading"] = typeof(CanvasLoadingAvailabilityState), + ["empty"] = typeof(CanvasEmptyAvailabilityState), + ["ready"] = typeof(CanvasReadyAvailabilityState), + ["failed"] = typeof(CanvasFailedAvailabilityState), + }, + allowUnknown: false) + { + } +} + /// /// ChatOrigin describes how a chat came into existence. /// diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 1f8c3437..0418dbbb 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,10 +14,11 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "0.9.0"; + public const string Current = "0.10.0"; private static readonly string[] s_supported = { + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs index a287f30b..b80be615 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpUnion.cs @@ -14,6 +14,8 @@ namespace Microsoft.AgentHostProtocol; /// public abstract class AhpUnion { + internal string? Discriminator { get; set; } + /// /// The active variant value. Either one of the union's concrete payload /// types, a raw for an unknown diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs index f150688e..393036d8 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs @@ -65,6 +65,7 @@ protected UnionConverter( { JsonTypeInfo typeInfo = options.GetTypeInfo(variantType); result.Value = JsonSerializer.Deserialize(root, typeInfo); + result.Discriminator = disc; } else if (_allowUnknown) { @@ -97,9 +98,60 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions return; } - // Serialize by the runtime type so every property (including the - // variant's own discriminator field) is written. JsonTypeInfo typeInfo = options.GetTypeInfo(inner.GetType()); - JsonSerializer.Serialize(writer, inner, typeInfo); + JsonElement serialized = JsonSerializer.SerializeToElement(inner, typeInfo); + if (serialized.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + $"{typeof(T).Name} variant '{inner.GetType().Name}' must serialize as an object"); + } + + string? wireValue = null; + if (serialized.TryGetProperty(_discriminator, out JsonElement serializedDiscriminator) + && serializedDiscriminator.ValueKind == JsonValueKind.String) + { + wireValue = serializedDiscriminator.GetString(); + } + + wireValue ??= value?.Discriminator; + if (wireValue is not null + && (!_variants.TryGetValue(wireValue, out Type? storedType) + || storedType != inner.GetType())) + { + wireValue = null; + } + + foreach (KeyValuePair variant in _variants) + { + if (wireValue is not null) + { + break; + } + + if (variant.Value == inner.GetType()) + { + wireValue = variant.Key; + break; + } + } + + if (wireValue is null) + { + throw new JsonException( + $"Unknown {typeof(T).Name} variant type '{inner.GetType().Name}'"); + } + + writer.WriteStartObject(); + writer.WriteString(_discriminator, wireValue); + foreach (JsonProperty property in serialized.EnumerateObject()) + { + if (property.NameEquals(_discriminator)) + { + continue; + } + + property.WriteTo(writer); + } + writer.WriteEndObject(); } } diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs index bfaa9f2c..ef60a870 100644 --- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -55,6 +55,21 @@ public static bool TryGetActionType(object action, out ActionType actionType) case AutomationUpdateRequestedAction value: actionType = value.Type; return true; + case CanvasAvailabilityChangedAction value: + actionType = value.Type; + return true; + case CanvasIconChangedAction value: + actionType = value.Type; + return true; + case CanvasIncarnationChangedAction value: + actionType = value.Type; + return true; + case CanvasTitleChangedAction value: + actionType = value.Type; + return true; + case CanvasTrustChangedAction value: + actionType = value.Type; + return true; case ChangesetClearedAction value: actionType = value.Type; return true; @@ -193,6 +208,12 @@ public static bool TryGetActionType(object action, out ActionType actionType) case SessionActivityChangedAction value: actionType = value.Type; return true; + case SessionCanvasRemovedAction value: + actionType = value.Type; + return true; + case SessionCanvasSetAction value: + actionType = value.Type; + return true; case SessionChangesetsChangedAction value: actionType = value.Type; return true; @@ -381,6 +402,11 @@ public static string GetWireName(ActionType actionType) => ActionType.AutomationRunSessionSet => "automationRun/sessionSet", ActionType.AutomationSet => "automation/set", ActionType.AutomationUpdateRequested => "automation/updateRequested", + ActionType.CanvasAvailabilityChanged => "canvas/availabilityChanged", + ActionType.CanvasIconChanged => "canvas/iconChanged", + ActionType.CanvasIncarnationChanged => "canvas/incarnationChanged", + ActionType.CanvasTitleChanged => "canvas/titleChanged", + ActionType.CanvasTrustChanged => "canvas/trustChanged", ActionType.ChangesetCleared => "changeset/cleared", ActionType.ChangesetContentChanged => "changeset/contentChanged", ActionType.ChangesetFileRemoved => "changeset/fileRemoved", @@ -427,6 +453,8 @@ public static string GetWireName(ActionType actionType) => ActionType.SessionActiveClientRemoved => "session/activeClientRemoved", ActionType.SessionActiveClientSet => "session/activeClientSet", ActionType.SessionActivityChanged => "session/activityChanged", + ActionType.SessionCanvasRemoved => "session/canvasRemoved", + ActionType.SessionCanvasSet => "session/canvasSet", ActionType.SessionChangesetsChanged => "session/changesetsChanged", ActionType.SessionChatAdded => "session/chatAdded", ActionType.SessionChatRemoved => "session/chatRemoved", diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index f132f9d8..323d15c7 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -25,7 +25,7 @@ public enum ReduceOutcome /// /// Pure reducers for the Agent Host Protocol. , /// , , and -/// apply a to the +/// , and apply a to the /// matching state tree in place. /// public static class Reducers @@ -876,6 +876,36 @@ public static ReduceOutcome ApplyToSession(SessionState state, StateAction actio }); case SessionChatAddedAction a: return ApplySessionChatAdded(state, a); + case SessionCanvasSetAction a: + state.Canvases ??= []; + int canvasIndex = state.Canvases.FindIndex(c => c.Resource == a.Canvas.Resource); + if (canvasIndex < 0) + { + state.Canvases.Add(a.Canvas); + return ReduceOutcome.Applied; + } + + if (a.Canvas.Revision <= state.Canvases[canvasIndex].Revision) + { + return ReduceOutcome.NoOp; + } + + state.Canvases[canvasIndex] = a.Canvas; + return ReduceOutcome.Applied; + case SessionCanvasRemovedAction a: + if (state.Canvases is null) + { + return ReduceOutcome.NoOp; + } + + int removalIndex = state.Canvases.FindIndex(c => c.Resource == a.Resource); + if (removalIndex < 0) + { + return ReduceOutcome.NoOp; + } + + state.Canvases.RemoveAt(removalIndex); + return ReduceOutcome.Applied; case SessionChatRemovedAction a: return ApplySessionChatRemoved(state, a); case SessionChatUpdatedAction a: @@ -2348,6 +2378,64 @@ public static ReduceOutcome ApplyToAnnotations(AnnotationsState state, StateActi return ReduceOutcome.OutOfScope; } + /// Applies an action to canvas state while rejecting stale or duplicate revisions. + public static ReduceOutcome ApplyToCanvas(CanvasState state, StateAction action) + { + Guard.ThrowIfNull(state, nameof(state)); + Guard.ThrowIfNull(action, nameof(action)); + + switch (action.Value) + { + case CanvasAvailabilityChangedAction changed: + if (changed.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + + state.Availability = changed.Availability; + state.Revision = changed.Revision; + return ReduceOutcome.Applied; + case CanvasTrustChangedAction changed: + if (changed.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + + state.Trust = changed.Trust; + state.Revision = changed.Revision; + return ReduceOutcome.Applied; + case CanvasIncarnationChangedAction changed: + if (changed.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + + state.Identity = state.Identity with { Incarnation = changed.Incarnation }; + state.Revision = changed.Revision; + return ReduceOutcome.Applied; + case CanvasTitleChangedAction changed: + if (changed.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + + state.Title = changed.Title; + state.Revision = changed.Revision; + return ReduceOutcome.Applied; + case CanvasIconChangedAction changed: + if (changed.Revision <= state.Revision) + { + return ReduceOutcome.NoOp; + } + + state.Icon = changed.Icon; + state.Revision = changed.Revision; + return ReduceOutcome.Applied; + default: + return ReduceOutcome.OutOfScope; + } + } + /// Applies an action to the automation catalogue in place. public static ReduceOutcome ApplyToAutomation( AutomationState state, diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs index 4bf9390e..86a04db6 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs @@ -72,6 +72,9 @@ public void ReducerMatchesFixture(string name, string path) case "resourceWatch": RunFixture(initial, expected, actions, Reducers.ApplyToResourceWatch); break; + case "canvas": + RunFixture(initial, expected, actions, Reducers.ApplyToCanvas); + break; case "annotations": RunFixture(initial, expected, actions, Reducers.ApplyToAnnotations); break; diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs index d0fa9c1d..6e4d2a43 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs @@ -68,6 +68,15 @@ public void ProtocolVersionConstants() Assert.Equal(ProtocolVersion.Current, ProtocolVersion.Supported[0]); } + [Fact] + public void CanvasIconChangedRequiresRevision() + { + Assert.Throws(() => + Ser.Deserialize("""{"type":"canvas/iconChanged","icon":null}""")); + Assert.Throws(() => + Ser.Deserialize("""{"type":"canvas/iconChanged","icon":null,"revision":null}""")); + } + // ── Verifier ────────────────────────────────────────────────────────── private static void VerifyFixture(string path) diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 6da0af86..3b54c8fb 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -840,6 +840,26 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct } state.Chats = append(state.Chats, a.Summary) return ReduceOutcomeApplied + case *ahptypes.SessionCanvasSetAction: + for i := range state.Canvases { + if state.Canvases[i].Resource == a.Canvas.Resource { + if a.Canvas.Revision <= state.Canvases[i].Revision { + return ReduceOutcomeNoOp + } + state.Canvases[i] = a.Canvas + return ReduceOutcomeApplied + } + } + state.Canvases = append(state.Canvases, a.Canvas) + return ReduceOutcomeApplied + case *ahptypes.SessionCanvasRemovedAction: + for i := range state.Canvases { + if state.Canvases[i].Resource == a.Resource { + state.Canvases = append(state.Canvases[:i], state.Canvases[i+1:]...) + return ReduceOutcomeApplied + } + } + return ReduceOutcomeNoOp case *ahptypes.SessionChatRemovedAction: for i := range state.Chats { if state.Chats[i].Resource == a.Chat { @@ -1853,6 +1873,46 @@ func ApplyActionToResourceWatch(state *ahptypes.ResourceWatchState, action ahpty return ReduceOutcomeOutOfScope } +// ApplyActionToCanvas applies an action to canvas state while rejecting stale +// or duplicate revisions. +func ApplyActionToCanvas(state *ahptypes.CanvasState, action ahptypes.StateAction) ReduceOutcome { + switch a := action.Value.(type) { + case *ahptypes.CanvasAvailabilityChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Availability = a.Availability + state.Revision = a.Revision + case *ahptypes.CanvasTrustChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Trust = a.Trust + state.Revision = a.Revision + case *ahptypes.CanvasIncarnationChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Identity.Incarnation = a.Incarnation + state.Revision = a.Revision + case *ahptypes.CanvasTitleChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Title = a.Title + state.Revision = a.Revision + case *ahptypes.CanvasIconChangedAction: + if a.Revision <= state.Revision { + return ReduceOutcomeNoOp + } + state.Icon = a.Icon + state.Revision = a.Revision + default: + return ReduceOutcomeOutOfScope + } + return ReduceOutcomeApplied +} + // ApplyActionToAutomation applies an action to automation catalogue state. func ApplyActionToAutomation(state *ahptypes.AutomationState, action ahptypes.StateAction) ReduceOutcome { switch a := action.Value.(type) { diff --git a/clients/go/ahp/reducers_fixture_test.go b/clients/go/ahp/reducers_fixture_test.go index a1617d4b..97a6e4ab 100644 --- a/clients/go/ahp/reducers_fixture_test.go +++ b/clients/go/ahp/reducers_fixture_test.go @@ -153,6 +153,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) case "automation": runFixture[ahptypes.AutomationState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAutomation) case "automationRun": @@ -192,7 +194,11 @@ func runFixture[T any](t *testing.T, initial, expected json.RawMessage, actions } actual := stripNulls(reMarshal(t, &state)) - want := stripNulls(parseJSON(t, expected)) + var expectedState T + if err := json.Unmarshal(expected, &expectedState); err != nil { + t.Fatalf("decode expected state: %v", err) + } + want := stripNulls(reMarshal(t, &expectedState)) if !reflect.DeepEqual(actual, want) { t.Fatalf("state mismatch:\nactual: %s\nexpected: %s", mustPretty(actual), mustPretty(want)) diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index d940c8bd..ea0ebc76 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -6,6 +6,7 @@ package ahptypes import ( "encoding/json" + "errors" ) // Reference the encoding/json import to keep gofmt -d from @@ -115,6 +116,13 @@ const ( ActionTypeAutomationRunSessionRemoved ActionType = "automationRun/sessionRemoved" ActionTypeAutomationRunPrimarySessionChanged ActionType = "automationRun/primarySessionChanged" ActionTypeAutomationRunCancelRequested ActionType = "automationRun/cancelRequested" + ActionTypeSessionCanvasSet ActionType = "session/canvasSet" + ActionTypeSessionCanvasRemoved ActionType = "session/canvasRemoved" + ActionTypeCanvasAvailabilityChanged ActionType = "canvas/availabilityChanged" + ActionTypeCanvasTrustChanged ActionType = "canvas/trustChanged" + ActionTypeCanvasIncarnationChanged ActionType = "canvas/incarnationChanged" + ActionTypeCanvasTitleChanged ActionType = "canvas/titleChanged" + ActionTypeCanvasIconChanged ActionType = "canvas/iconChanged" ) // ─── Action Envelope ───────────────────────────────────────────────── @@ -1651,6 +1659,128 @@ type AutomationRunCancelRequestedAction struct { Type ActionType `json:"type"` } +// A canvas was admitted (opened) or its catalog entry changed. +// +// Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the +// server dispatches this with the full entry to record a newly opened +// canvas, or to republish it after a trust/availability/incarnation change +// so subscribers following only the session channel stay in sync with +// {@link CanvasState}. Never client-dispatchable: admission is through +// `openCanvas` or host publication of a correlated, already-open native +// instance under that command's admission rules. Both paths MUST use the +// same singular identity-to-resource binding; repeated native observations +// MUST NOT create a second entry. A stale/out-of-order delivery +// (`canvas.revision` not strictly greater than the currently-recorded entry's +// revision) MUST be rejected (no-op) rather than overwrite a newer entry with +// older data. +type SessionCanvasSetAction struct { + Type ActionType `json:"type"` + // The canvas entry to add or update, matched by `resource`. + Canvas CanvasEntry `json:"canvas"` +} + +// A canvas was logically closed. +// +// Remove semantics keyed by `resource`: an unknown URI is a no-op. This +// represents durable membership removal, not a client hiding a local +// tab/view — see `closeCanvas`. +type SessionCanvasRemovedAction struct { + Type ActionType `json:"type"` + // Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + Resource URI `json:"resource"` +} + +// Replaces the canvas's live resolution state. +// +// Dispatched by the host on every availability transition, including +// initial resolution after admission by `openCanvas` or a correlated native +// open, provider restart, and endpoint failure/recovery. A client-local page +// reload or transient presentation credential renewal alone does not require +// this action or a revision change. +type CanvasAvailabilityChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasState.availability}. + Availability CanvasAvailabilityState `json:"availability"` + // The {@link CanvasState.revision} this action results in. The reducer + // MUST reject (no-op) this action if `revision` is not strictly greater + // than the canvas's current `revision` — this is how stale/out-of-order + // deliveries are consistently rejected across every canvas action, not + // just this one. + Revision int64 `json:"revision"` +} + +// Replaces the canvas's trust decision. +// +// Dispatched by the host whenever the execution-trust decision for this +// canvas's declared actions changes (e.g. a pending decision resolves, or an +// administrator revokes a previously trusted source). +type CanvasTrustChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasState.trust}. + Trust CanvasTrustState `json:"trust"` + // The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + Revision int64 `json:"revision"` +} + +// Records that the canvas's live endpoint was replaced by a fresh one for +// the same logical instance (e.g. the owning provider restarted). +// +// Renewing transient presentation credentials for the same live endpoint is +// not endpoint replacement and MUST NOT trigger this action. +// +// The host MUST dispatch {@link CanvasAvailabilityChangedAction} to +// transition through `notLoaded`/`loading` around this change. Receivers +// MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed +// callbacks addressed to a superseded `incarnation` — because `incarnation` +// is opaque (see {@link CanvasIdentity.incarnation}), that rejection is +// driven by the accompanying `revision` bump here, not by comparing +// `incarnation` values for order. +type CanvasIncarnationChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + Incarnation string `json:"incarnation"` + // The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + Revision int64 `json:"revision"` +} + +// Replaces the canvas's display title. +type CanvasTitleChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasState.title}. + Title string `json:"title"` + // The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + Revision int64 `json:"revision"` +} + +// Replaces or removes the canvas's display icon. +// +// This is presentation metadata only. It does not replace the live endpoint, +// change the canvas incarnation, or replay any canvas effect. +type CanvasIconChangedAction struct { + Type ActionType `json:"type"` + // New {@link CanvasState.icon}; `null` removes the current icon. + Icon *Icon `json:"icon"` + // The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + Revision int64 `json:"revision"` +} + +func (v *CanvasIconChangedAction) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if _, ok := fields["icon"]; !ok { + return errors.New("CanvasIconChangedAction: missing required field \"icon\"") + } + if raw, ok := fields["revision"]; !ok { + return errors.New("CanvasIconChangedAction: missing required field \"revision\"") + } else if string(raw) == "null" { + return errors.New("CanvasIconChangedAction: required field \"revision\" cannot be null") + } + type alias CanvasIconChangedAction + return json.Unmarshal(data, (*alias)(v)) +} + // ─── StateAction Union ─────────────────────────────────────────────── // StateAction is the discriminated union of every state action. @@ -1758,6 +1888,13 @@ func (*AutomationRunSessionSetAction) isStateAction() {} func (*AutomationRunSessionRemovedAction) isStateAction() {} func (*AutomationRunPrimarySessionChangedAction) isStateAction() {} func (*AutomationRunCancelRequestedAction) isStateAction() {} +func (*SessionCanvasSetAction) isStateAction() {} +func (*SessionCanvasRemovedAction) isStateAction() {} +func (*CanvasAvailabilityChangedAction) isStateAction() {} +func (*CanvasTrustChangedAction) isStateAction() {} +func (*CanvasIncarnationChangedAction) isStateAction() {} +func (*CanvasTitleChangedAction) isStateAction() {} +func (*CanvasIconChangedAction) isStateAction() {} // StateActionUnknown carries an unrecognized StateAction variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. type StateActionUnknown struct { @@ -2349,6 +2486,48 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "session/canvasSet": + var value SessionCanvasSetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "session/canvasRemoved": + var value SessionCanvasRemovedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/availabilityChanged": + var value CanvasAvailabilityChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/trustChanged": + var value CanvasTrustChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/incarnationChanged": + var value CanvasIncarnationChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/titleChanged": + var value CanvasTitleChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "canvas/iconChanged": + var value CanvasIconChangedAction + 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/ahptypes_test.go b/clients/go/ahptypes/ahptypes_test.go index 505278e9..3d27f19b 100644 --- a/clients/go/ahptypes/ahptypes_test.go +++ b/clients/go/ahptypes/ahptypes_test.go @@ -36,9 +36,11 @@ func TestActionEnvelopeRoundTrip(t *testing.T) { if err := json.Unmarshal([]byte(wire), &env); err != nil { t.Fatalf("unmarshal envelope: %v", err) } + if env.Channel != "ahp-session:/s1" { t.Errorf("channel = %q, want ahp-session:/s1", env.Channel) } + if env.ServerSeq != 7 { t.Errorf("serverSeq = %d, want 7", env.ServerSeq) } @@ -64,6 +66,29 @@ func TestActionEnvelopeRoundTrip(t *testing.T) { } } +func TestCanvasIconChangedRequiresIcon(t *testing.T) { + var action StateAction + err := json.Unmarshal( + []byte(`{"type":"canvas/iconChanged","revision":3}`), + &action, + ) + if err == nil { + t.Fatal("canvas/iconChanged without icon decoded successfully") + } +} + +func TestCanvasIconChangedRequiresRevision(t *testing.T) { + for _, wire := range []string{ + `{"type":"canvas/iconChanged","icon":null}`, + `{"type":"canvas/iconChanged","icon":null,"revision":null}`, + } { + var action StateAction + if err := json.Unmarshal([]byte(wire), &action); err == nil { + t.Fatalf("canvas/iconChanged with invalid revision decoded successfully: %s", wire) + } + } +} + // TestStateActionUnknownVariant ensures a discriminator value we don't // recognize is preserved verbatim in the Unknown variant and round-trips. func TestStateActionUnknownVariant(t *testing.T) { diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 9ef81b6f..84669c96 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -180,6 +180,22 @@ type InitializeResult struct { // `ahp-automations://` for {@link AutomationState}; absence means the // host does not expose an automation catalogue or automation commands. Automations *AutomationCapabilities `json:"automations,omitempty"` + // Host/runtime-owned local-canvas support. Presence means the SERVER + // currently has a working runtime able to serve `openCanvas` / + // `invokeCanvasAction` for at least one qualifying (explicitly installed + // and trust-eligible) extension/package source; absence means the host + // has no available canvas runtime, and clients MUST treat every canvas as + // {@link CanvasAvailabilityStatus.Unsupported} regardless of what + // {@link ClientCapabilities.canvases} declared. + // + // **Protocol version support alone is not a runtime capability**: a host + // speaking protocol `>= 0.10.0` without this field present MUST NOT be + // assumed to have a usable canvas runtime. This field — not the + // negotiated `protocolVersion` — is the authoritative signal, and is + // independent of any individual canvas's live availability + // ({@link CanvasAvailabilityState}) or trust decision + // ({@link CanvasTrustState}). + Canvases *CanvasCapabilities `json:"canvases,omitempty"` } // Optional capabilities a client declares during `initialize`. @@ -200,6 +216,23 @@ type ClientCapabilities struct { // capability is declared. Clients that omit it MUST treat // App-bearing tool calls as ordinary MCP tool calls. McpApps map[string]json.RawMessage `json:"mcpApps,omitempty"` + // Client can render local canvases: `listCanvasTypes`, `openCanvas`, + // subscribe to the resulting `ahp-canvas:` channel, and drive + // `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + // `closeCanvas`. + // + // Hosts SHOULD NOT offer canvas admission to a client that omits this + // capability; such a client MUST be treated as if every canvas were + // {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + // anything about server/runtime execution trust — see + // {@link CanvasTrustStatus}, which is a separate, host-owned decision. + // + // This declares only the CLIENT's rendering capability. Protocol version + // support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + // actually has a working canvas runtime — see + // {@link InitializeResult.canvases}, the server-side counterpart, which a + // client MUST also check before treating canvases as usable. + Canvases map[string]json.RawMessage `json:"canvases,omitempty"` } // Automation features supported by this host authority. @@ -225,6 +258,12 @@ type AutomationCapabilities struct { RunHistoryLimit *int64 `json:"runHistoryLimit,omitempty"` } +// Local-canvas runtime features supported by this host authority. The empty +// object means "supported" — see {@link InitializeResult.canvases} for what +// presence/absence of this field itself means. +type CanvasCapabilities struct { +} + // Presence capability for {@link AutomationCreateRequestedAction | // `automation/createRequested`}. // @@ -1314,6 +1353,304 @@ type FetchAutomationRunsParams struct { type FetchAutomationRunsResult struct { } +// Discovers canvas TYPES currently available to open for one exact backing +// chat. +// +// This is a **pure read/browse** operation: it MUST NOT execute or start a +// provider, or open, materialize, or otherwise admit any canvas. See +// `openCanvas` for the admission rules, including publication of an +// already-open native instance. This catalogue is unrelated to +// {@link SessionState.canvases}, which reflects durable membership of +// already-opened canvas INSTANCES, not the set of canvas TYPES a +// host/extension could open; do not confuse the two. +type ListCanvasTypesParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Maximum number of entries to return in this page. The server SHOULD respect + // this bound but MAY return fewer entries and MAY impose its own upper cap. + // Omit to let the server choose the page size. + Limit *int64 `json:"limit,omitempty"` + // Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + // Omit to fetch the first page. Cursors are server-defined and MUST be treated + // as opaque — do not parse, modify, or persist them across connections. An + // unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + Cursor *string `json:"cursor,omitempty"` +} + +// Available canvas types for the requested chat. +type ListCanvasTypesResult struct { + // Opaque cursor for the next page. Present when more entries exist beyond the + // returned page; absent signals the end of the collection. Pass it back as + // {@link PaginatedParams.cursor} to fetch the following page. + NextCursor *string `json:"nextCursor,omitempty"` + // Discovered canvas type declarations. + Types []CanvasTypeDeclaration `json:"types"` +} + +// Explicitly opens (admits) a canvas, associating it with the owning chat +// given by `identity.chat` at the moment of the call — never with whichever +// chat later happens to have focus. +// +// Canvas membership requires explicit admission. A client admits a canvas +// by calling `openCanvas`, a read-write operation. `listCanvasTypes`, +// `subscribe`, and `resolveCanvasSource` MUST NOT admit a canvas or execute +// or start its provider. +// +// A host MAY also publish membership after observing an instance already +// opened by the owning native runtime. Before publication, the host MUST +// correlate the observation to the actual backing chat, canonical source, +// canvas type, and native instance, and enforce applicable +// execution-admission policy. Uncorrelated or conflicting observations MUST +// be rejected rather than assigned to the focused chat or a guessed source. +// Observation does not grant execution trust: it MUST NOT convert `pending` +// or `blocked` trust to `trusted`; trust and availability remain independent. +// +// Native publication follows the same singular identity-to-resource binding +// and authoritative state rules as client-originated admission. The host +// MUST NOT manufacture a client `openCanvas` request or invoke the provider's +// open handler again merely to publish an already-open instance. Repeated +// observations MUST NOT duplicate membership; this does not suppress the +// actual effects of a genuinely new native open. Hosts MUST preserve the +// native instance-ID namespace, including session-wide IDs across providers +// where the owning runtime requires them, rather than hide collisions with +// an invented provider namespace. Client `requestId` semantics are unchanged. +// +// Once admitted by either path, clients read and follow live state by +// `subscribe`-ing to `canvas.resource`, and resolve the current live endpoint +// via `resolveCanvasSource`; neither read opens, resumes, or restarts anything. +// +// **Logical identity is always singular.** The same {@link CanvasIdentityKey} +// (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same +// `canvas` resource URI and the same {@link SessionState.canvases} catalog +// entry, no matter how many times `openCanvas` is called or a native open is +// observed for it. The server MUST reuse that existing entry's `resource` +// rather than mint a second one. A client-supplied `canvas` URI is honored +// only on the call that first establishes the identity; on a later call for +// an already-recorded identity the server MUST ignore the supplied `canvas` +// value and return the existing resource instead. +// +// **Idempotency is scoped to `requestId`, not identity.** Retrying with the +// exact same `requestId` and byte-for-byte identical params from the same +// authenticated connection MUST return the original result without +// repeating any side effect, within a bounded live window (the server is +// not required to remember it forever). Reusing the same `requestId` with +// any different parameter value MUST be rejected with `Conflict` +// (`-32011`) — mint a new `requestId` for a new logical call. A genuinely +// NEW `requestId` for an already-open identity MAY be effectful (e.g. +// updating `title`/`icon`, or causing the provider to re-run its own +// open-time initialization with new `input`) — this mirrors the pinned +// SDK's own repeated-open behavior and does not create a second logical +// identity. There is no exactly-once-across-crash guarantee: a lost reply +// is indeterminate, and clients MUST NOT automatically replay `openCanvas` +// — reconnect and read `SessionState.canvases` / `resolveCanvasSource` +// instead to determine the actual outcome. +type OpenCanvasParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + Canvas URI `json:"canvas"` + // Logical identity to open or re-admit. + Identity CanvasIdentityKey `json:"identity"` + // Initial (or updated, on a later effectful call) display title. + Title string `json:"title"` + // Initial (or updated) display icon. + Icon *Icon `json:"icon,omitempty"` + // Bounded JSON input for this open call (e.g. seed parameters the + // provider uses to initialize the canvas), opaque to the protocol. See + // {@link CanvasTypeDeclaration.openInputSchema} / + // `openInputSchemaRef` for the expected shape. The JSON-serialized value + // MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + Input *json.RawMessage `json:"input,omitempty"` + // Durable client-generated idempotency key bounding retry deduplication + // for this call within a live window; see the idempotency rules above. + // MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` +} + +// Result identifying the existing or newly opened canvas. +type OpenCanvasResult struct { + // The catalog entry for the opened (or already-open) canvas. + Canvas CanvasEntry `json:"canvas"` +} + +// Reads a canvas's current live-resolution state and, when currently live, +// a transient endpoint presentation. +// +// This is read-only with respect to membership, provider execution/lifecycle, +// and durable canvas state. It MUST NOT admit a canvas or create, resume, +// reopen, or restart a provider, including on unavailable or unauthorized +// requests. Authorization failure MUST NOT expose `source`. +// +// For an already-live endpoint, authorized resolution MAY issue or refresh +// transient presentation credentials while constructing the response. Two +// resolutions of the same live incarnation MAY therefore return different +// URLs. Credential refresh alone MUST NOT rerun provider open, change the +// incarnation, or require a canvas state revision. If the live state changes +// concurrently during resolution, the response MUST report that state's +// current availability, revision, and incarnation; any returned `source` +// MUST correspond to that reported state. +// +// Clients MUST NOT replace a newer attachment with a superseded resolution +// response, even when credential renewal leaves `revision` and `incarnation` +// unchanged. These state guards do not order same-state credential refreshes. +// +// If the canvas does not currently have a live endpoint, `source` is absent +// and `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`). +// Call `restartCanvasProvider` (an explicitly effectful operation) to +// attempt recovery instead; calling `resolveCanvasSource` again only +// retries reading the current state without restarting anything. +// +// A client-local page reload needs no provider restart or new effectful +// command. Before reload or reattachment, the client SHOULD resolve a fresh +// presentation unless the existing credential is known to remain valid and +// reusable. An absent expiry hint does not imply indefinite validity or +// reusability. Presentation URLs and credentials MUST NOT enter durable +// membership, editor restoration data, or routine logs. +type ResolveCanvasSourceParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// The canvas's current live-resolution state as of this read. +type ResolveCanvasSourceResult struct { + // Current {@link CanvasEntry.availability}. + Availability CanvasAvailabilityStatus `json:"availability"` + // Current {@link CanvasIdentity.incarnation}. + Incarnation string `json:"incarnation"` + // Current {@link CanvasEntry.revision}. + Revision int64 `json:"revision"` + // Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + Source *CanvasSourcePresentation `json:"source,omitempty"` +} + +// Invokes one of a canvas's currently declared actions exactly once. +// +// The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's +// current trust is not `trusted`, and with `NotFound` (`-32008`) if +// `actionId` does not match a currently declared action. `incarnation` is +// REQUIRED — omitting stale-generation protection on an effectful call is +// not allowed. If it does not match the canvas's current +// {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` +// (`-32011`) rather than route the call to a superseded endpoint. +// +// The result is the provider's raw reply and is never persisted into +// `CanvasState` — large or provider-specific payloads stay off the durable +// state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be +// represented out of band instead of being returned inline. Any resulting +// state changes (e.g. a subsequent availability transition) flow back +// separately through the normal `canvas/*` action stream on the canvas's +// own channel. +// +// A lost reply (e.g. a dropped connection after the provider already ran +// the handler) is **indeterminate**: clients MUST NOT automatically replay +// `invokeCanvasAction` on reconnect. Instead, reconnect and read the +// canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and +// decide from observed `revision`/`incarnation` and any provider-visible +// side effect whether to surface the ambiguity to the user, rather than +// assuming success or failure. +type InvokeCanvasActionParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + ActionId string `json:"actionId"` + // Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + // if any. The JSON-serialized value MUST NOT exceed + // `CANVAS_INPUT_MAX_LENGTH`. + Input *json.RawMessage `json:"input,omitempty"` + // Expected {@link CanvasIdentity.incarnation}. Required — see above. The + // server MUST reject the call with `Conflict` if the canvas's live + // endpoint has since been superseded, rather than deliver the call to it. + Incarnation string `json:"incarnation"` + // Durable client-generated idempotency key bounding retry + // deduplication for this invocation within a live window. The server is + // not required to guarantee exactly-once execution across a crash. MUST + // NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` +} + +// Result of invoking a declared canvas action. +type InvokeCanvasActionResult struct { + // The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + Result json.RawMessage `json:"result"` +} + +// Explicitly restarts the provider/chat-scoped runtime backing this canvas: +// retires the current live endpoint and establishes a fresh one for the +// same logical instance. +// +// This is the **only** operation that intentionally causes an +// {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` +// (read-only source resolution / client-local page reload) MUST NEVER +// trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} +// (transitioning through `notLoaded`/`loading`) and then +// {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart +// never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or +// restore any prior in-flight effect. +// +// `incarnation` is REQUIRED: the server MUST reject with `Conflict` +// (`-32011`) if it does not match the canvas's current +// {@link CanvasIdentity.incarnation}, so a caller cannot restart a +// generation it never observed (e.g. after racing a concurrent restart). A +// lost reply is indeterminate; clients MUST NOT automatically replay this +// command — reconnect and compare the canvas's current `incarnation` +// instead. +type RestartCanvasProviderParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Durable client-generated idempotency key, following the same + // requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + // `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` + // Expected current {@link CanvasIdentity.incarnation}; required — see above. + Incarnation string `json:"incarnation"` +} + +// Logically closes a canvas: removes its durable membership from +// `SessionState.canvases` and disposes matching views. +// +// This is distinct from a client merely hiding a local tab or view, which is +// presentation-only and MUST NOT dispatch this command. There is no +// advertised model tool for this operation — it is invoked only by +// UI/RPC callers. +// +// `revision` is REQUIRED: the server MUST reject with `Conflict` +// (`-32011`) if it does not match the canvas's current +// {@link CanvasEntry.revision}, so a caller cannot close membership state it +// never actually observed. If no matching entry exists (e.g. already +// closed), the server MUST treat this as a successful no-op rather than an +// error — the `revision` precondition only applies when an entry still +// exists. A lost reply is indeterminate; clients MUST NOT automatically +// replay this command — reconnect and check `SessionState.canvases` +// instead. +type CloseCanvasParams struct { + // Channel URI this command targets. + Channel URI `json:"channel"` + // Optional JSON-serializable metadata associated with this request. + // Receivers MUST ignore keys they do not understand. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` + // Durable client-generated idempotency key, following the same + // requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + // `CANVAS_REQUEST_ID_MAX_LENGTH`. + RequestId string `json:"requestId"` + // Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + Revision int64 `json:"revision"` +} + func (v *ForkChatSource) UnmarshalJSON(data []byte) error { disc, ok, err := readDiscriminator(data, "kind") if err != nil { diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 269b50f5..4e50cdd1 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -540,6 +540,62 @@ const ( AutomationRunOriginKindTrigger AutomationRunOriginKind = "trigger" ) +// Discriminant for {@link CanvasSource} — what kind of package originates a +// canvas type. +type CanvasSourceKind string + +const ( + // An explicitly installed host extension. + CanvasSourceKindExtension CanvasSourceKind = "extension" + // An explicitly installed package (not a host extension). + CanvasSourceKindPackage CanvasSourceKind = "package" +) + +// Discriminant for {@link CanvasTrustState} — whether the host currently +// permits this canvas's declared actions to execute. +// +// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +// a canvas may be perfectly capable of rendering while blocked from +// executing actions, and vice versa. Trust decisions are host/runtime +// authority, not something this protocol grants. +type CanvasTrustStatus string + +const ( + // Declared actions may be invoked. + CanvasTrustStatusTrusted CanvasTrustStatus = "trusted" + // A trust decision has not yet been made (e.g. first use of a new/changed source). + CanvasTrustStatusPending CanvasTrustStatus = "pending" + // The host has denied execution; declared actions MUST NOT be invoked. + CanvasTrustStatusBlocked CanvasTrustStatus = "blocked" +) + +// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +// live resolution state, independent of its durable +// {@link CanvasEntry | membership} in a session's catalog. +// +// An empty catalog membership list is not itself a close, and a canvas may +// remain a recorded member while its live availability cycles through these +// states any number of times (e.g. across provider restarts). +type CanvasAvailabilityStatus string + +const ( + // The connected client or host does not support this canvas type (e.g. + // the client omitted the `canvases` capability, or no local runtime can + // render this `canvasType`). Distinct from `blocked` trust, which is a + // policy decision rather than a capability gap. + CanvasAvailabilityStatusUnsupported CanvasAvailabilityStatus = "unsupported" + // Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + CanvasAvailabilityStatusNotLoaded CanvasAvailabilityStatus = "notLoaded" + // Currently resolving or (re)connecting to a live endpoint. + CanvasAvailabilityStatusLoading CanvasAvailabilityStatus = "loading" + // Live and reachable, but the provider has not yet produced content to render. + CanvasAvailabilityStatusEmpty CanvasAvailabilityStatus = "empty" + // Live, reachable, and has declared its current actions. + CanvasAvailabilityStatusReady CanvasAvailabilityStatus = "ready" + // The live endpoint failed to resolve, or resolution otherwise failed. + CanvasAvailabilityStatusFailed CanvasAvailabilityStatus = "failed" +) + // ─── Structs ────────────────────────────────────────────────────────── // An optionally-sized icon that can be displayed in a user interface. @@ -933,6 +989,15 @@ type SessionState struct { // before subscribing. See {@link Changeset} for the full shape and // {@link /guide/changesets | Changesets} for an overview of the model. Changesets []Changeset `json:"changesets,omitempty"` + // Catalog of canvases opened for chats in this session. Presence is + // durable logical membership, admitted via `openCanvas` or host publication + // of a correlated, already-open native instance under that command's + // admission rules. Membership is never implied by discovery, subscription, + // source resolution, a chat's existence, or a client's earlier focus. + // Each entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the + // exact backing chat; a canvas never migrates to a different chat. See + // {@link CanvasEntry} for the full membership/availability/trust model. + Canvases []CanvasEntry `json:"canvases,omitempty"` // Outstanding input the session is blocked on, aggregated across every chat // so a client can discover and answer it from the session channel alone, // without subscribing to individual chats. @@ -4099,6 +4164,303 @@ type AutomationRunState struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// A canvas type provided by an installed host extension. +// +// `extensionId` is the identity-bearing field for comparison purposes (see +// {@link CanvasIdentityKey}). `version` is display/informational metadata +// only — it MUST NOT be treated as identity-bearing (two `CanvasSource` +// values that differ only in `version` are the same source). +type CanvasExtensionSource struct { + // Stable extension identifier (host-defined format, e.g. `publisher.name`). + // MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + ExtensionId string `json:"extensionId"` + // Installed extension version, when known. Metadata only — not identity-bearing. + Version *string `json:"version,omitempty"` +} + +// A canvas type provided by an installed package that is not a host +// extension (e.g. a workspace-declared runtime package). +// +// `sourceId` — not `packageName` — is the identity-bearing field: the same +// declared package name MAY be installed in more than one scope (e.g. a +// workspace-local copy and a globally-installed copy, or two different +// registries), and each such installation is a distinct source with its own +// `sourceId`. `packageName` and `version` are display/informational metadata +// only and MUST NOT be treated as identity-bearing. +type CanvasPackageSource struct { + // Stable, host- or package-manager-assigned unique identifier for this + // specific installed package instance/scope (opaque format). This is the + // identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + SourceId string `json:"sourceId"` + // Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + PackageName string `json:"packageName"` + // Installed package version, when known. Metadata only — not identity-bearing. + Version *string `json:"version,omitempty"` +} + +// The logical identity of a canvas, excluding the host-assigned +// {@link CanvasIdentity.incarnation | `incarnation`}. +// +// Two canvases are the same logical canvas iff `chat`, `canvasType`, +// `instanceId`, and `source`'s **identity-bearing** fields are all equal: +// `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` +// plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and +// `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into +// this comparison. Clients MUST NOT treat +// {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — +// it is only unique within the scope of `(chat, source, canvasType)`. +// +// This logical tuple does not widen the owning runtime's native instance-ID +// namespace. A runtime may require session-wide native IDs across providers; +// hosts MUST preserve that constraint rather than hide native collisions +// with an invented provider namespace. +type CanvasIdentityKey struct { + // The exact backing chat this canvas belongs to. A canvas is never + // re-associated with a different chat; opening a new one for another chat + // creates a distinct canvas. + Chat URI `json:"chat"` + // The extension or package that declares this canvas's type. + Source CanvasSource `json:"source"` + // Provider-declared canvas type (host/provider-defined format). MUST NOT + // exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + CanvasType string `json:"canvasType"` + // Provider-chosen stable identifier for this canvas instance, scoped to + // `(chat, source, canvasType)`. Stable across reloads and host/window + // restarts for the same logical canvas. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + InstanceId string `json:"instanceId"` +} + +// Full identity of a canvas, including the host-assigned +// {@link CanvasIdentity.incarnation | `incarnation`}. +type CanvasIdentity struct { + // The exact backing chat this canvas belongs to. A canvas is never + // re-associated with a different chat; opening a new one for another chat + // creates a distinct canvas. + Chat URI `json:"chat"` + // The extension or package that declares this canvas's type. + Source CanvasSource `json:"source"` + // Provider-declared canvas type (host/provider-defined format). MUST NOT + // exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + CanvasType string `json:"canvasType"` + // Provider-chosen stable identifier for this canvas instance, scoped to + // `(chat, source, canvasType)`. Stable across reloads and host/window + // restarts for the same logical canvas. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + InstanceId string `json:"instanceId"` + // Opaque, host-generated token identifying the current generation of this + // canvas's live endpoint. The host mints a fresh token whenever a provider + // restart retires the previous live endpoint and establishes a new one for + // the same logical instance (see {@link CanvasIncarnationChangedAction | + // `canvas/incarnationChanged`}); it is not changed by a plain page reload + // or transient presentation credential renewal for the same still-live + // endpoint. + // + // `incarnation` is **opaque**: clients and hosts MUST compare it only for + // equality, never parse it, sort it, or perform arithmetic on it (e.g. it + // is not guaranteed to be numeric or monotonically increasing). The host + // MUST NOT reuse a token for this logical identity once it has been + // superseded, including across a host/process restart — if the host + // cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + // or timestamp-derived) that make accidental reuse practically + // impossible, rather than a small resettable counter. + // + // Clients and hosts use `incarnation` to reject stale callbacks and + // in-flight effects addressed to a superseded endpoint. + Incarnation string `json:"incarnation"` +} + +type CanvasTrustedState struct { +} + +type CanvasPendingTrustState struct { +} + +type CanvasBlockedTrustState struct { + // Optional human-readable reason surfaced to the user. + Reason *string `json:"reason,omitempty"` +} + +// One action a canvas declares it can perform, invoked via +// `invokeCanvasAction`. +// +// Declarations are carried only on the full {@link CanvasState}, loaded when +// a client subscribes — never duplicated into the lightweight +// {@link CanvasEntry} catalog entry, keeping session summaries small. +type CanvasActionDeclaration struct { + // Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + Id string `json:"id"` + // Human-readable display name. + Title *string `json:"title,omitempty"` + // Description of what invoking the action does. + Description *string `json:"description,omitempty"` + // Inline JSON Schema for the expected `input`, when small enough to embed + // (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + // checked by {@link isCanvasSchemaWithinLimits}). Optional because some + // declared actions take no input. Mutually exclusive with + // `inputSchemaRef` — a declaration MUST supply at most one of the two. + InputSchema *json.RawMessage `json:"inputSchema,omitempty"` + // Bounded out-of-band reference to a larger JSON Schema, used instead of + // `inputSchema` when the schema would exceed + // {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + // inlined. AHP does not mandate a specific resolution mechanism for this + // URI (e.g. a host MAY make it `resourceRead`-able). + InputSchemaRef *URI `json:"inputSchemaRef,omitempty"` +} + +type CanvasUnsupportedAvailabilityState struct { +} + +type CanvasNotLoadedAvailabilityState struct { +} + +type CanvasLoadingAvailabilityState struct { +} + +type CanvasEmptyAvailabilityState struct { +} + +type CanvasReadyAvailabilityState struct { + // Actions currently declared by the live provider (full replacement each time this state is produced). + Actions []CanvasActionDeclaration `json:"actions"` +} + +type CanvasFailedAvailabilityState struct { + // Stable machine-readable and human-readable failure information. + Error ErrorInfo `json:"error"` +} + +// Lightweight catalog entry for a canvas, carried in +// {@link SessionState.canvases | `SessionState.canvases`}. Presence +// represents durable **logical membership** — it is unaffected by the live +// {@link CanvasEntry.availability | `availability`} cycling through +// `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. +// +// Membership is admitted by `openCanvas` or by host publication of a +// correlated, already-open native instance under that command's admission +// rules, never by discovery, subscription, or source resolution. +// +// The full state, including declared actions, lives in {@link CanvasState}, +// loaded when a client subscribes to {@link CanvasEntry.resource}. +type CanvasEntry struct { + // Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + Resource URI `json:"resource"` + // Full identity, including current incarnation. + Identity CanvasIdentity `json:"identity"` + // Human-readable display title. + Title string `json:"title"` + // Optional display icon. + Icon *Icon `json:"icon,omitempty"` + // Current trust decision matching {@link CanvasState.trust}. + Trust CanvasTrustState `json:"trust"` + // Current availability status matching {@link CanvasState.availability}'s discriminant. + Availability CanvasAvailabilityStatus `json:"availability"` + // Monotonically increasing counter bumped on every change to this + // canvas's state (trust, availability, or incarnation). Clients MAY use it + // to detect and reject stale reads without a full deep comparison. + // Transient presentation credential renewal alone does not require a + // revision change. + Revision int64 `json:"revision"` + // Opaque host-defined summary metadata. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// Full state for a single canvas, loaded when a client subscribes to the +// canvas's URI. +// +// `CanvasState` **denormalizes** every {@link CanvasEntry} field directly +// onto itself, replacing `availability`'s lightweight status with the full +// {@link CanvasAvailabilityState} (including declared actions or failure +// detail). Producers MUST keep the two representations consistent: any +// change to the inlined fields SHOULD also be announced on the owning +// session via {@link SessionCanvasSetAction | `session/canvasSet`}. +type CanvasState struct { + // URI of this canvas channel. + Resource URI `json:"resource"` + // Full identity, including current incarnation. + Identity CanvasIdentity `json:"identity"` + // Human-readable display title. + Title string `json:"title"` + // Optional display icon. + Icon *Icon `json:"icon,omitempty"` + // Current trust decision. + Trust CanvasTrustState `json:"trust"` + // Current live resolution state. + Availability CanvasAvailabilityState `json:"availability"` + // Matches {@link CanvasEntry.revision}. + Revision int64 `json:"revision"` + // Opaque host-defined metadata. + Meta map[string]json.RawMessage `json:"_meta,omitempty"` +} + +// A canvas type an installed extension or package currently makes available +// to open for a chat, as returned by `listCanvasTypes`. +// +// `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is +// unrelated to {@link CanvasEntry}, which represents durable membership of +// an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the +// catalogue (via `listCanvasTypes`) MUST NOT execute or start a provider, +// or open, materialize, or admit a canvas. Membership requires `openCanvas` +// or host publication of a correlated, already-open native instance under +// that command's admission rules. +type CanvasTypeDeclaration struct { + // The extension or package that declares this canvas type. + Source CanvasSource `json:"source"` + // Provider-declared canvas type (host/provider-defined format), passed as + // {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + // {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + CanvasType string `json:"canvasType"` + // Human-readable display name for a canvas-type picker. + Title string `json:"title"` + // Description of what this canvas type does. + Description *string `json:"description,omitempty"` + // Optional display icon. + Icon *Icon `json:"icon,omitempty"` + // Inline JSON Schema describing the `openCanvas` `input` this type + // expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + // / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + // `openInputSchemaRef`. + OpenInputSchema *json.RawMessage `json:"openInputSchema,omitempty"` + // Bounded out-of-band reference to a larger open-input JSON Schema, used + // instead of `openInputSchema` when it would exceed + // {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + // inlined. + OpenInputSchemaRef *URI `json:"openInputSchemaRef,omitempty"` + // Advisory, statically-known preview of actions this canvas type + // typically declares once opened (bounded to + // {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + // the actual invocable actions for an opened instance are always + // {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + // depend on live provider configuration) and MUST be used instead of this + // preview once the canvas is open. + DeclaredActions []CanvasActionDeclaration `json:"declaredActions,omitempty"` +} + +// Transient, renderer-neutral presentation of a canvas's current live +// endpoint, returned by `resolveCanvasSource`. +// +// This is a plain URL, not any renderer- or process-model-specific handle +// (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview +// panel reference) — how a client actually presents it (a VS Code Webview, +// the Integrated Browser, or otherwise) is entirely a client/host +// implementation detail outside this protocol. +type CanvasSourcePresentation struct { + // Ephemeral URL to the canvas's current live endpoint. Transient: MUST + // NOT be persisted (including durable canvas/session state or editor + // restoration data), written to routine logs, or treated as a stable + // identity. A host MAY embed + // short-lived, single-use credentials in it; such credentials are never + // durable authority. Renewed credentials MAY produce a different URL for + // the same incarnation and revision. Reuse is safe only while the + // credential is known to remain valid and reusable. + Url string `json:"url"` + // Advisory expiry hint for `url` (and any embedded credential), when + // known. Omission does not imply indefinite validity or reusability, and + // an unexpired credential may still be single-use. + ExpiresAt *string `json:"expiresAt,omitempty"` +} + // ─── Customization Enablement Union ─────────────────────────────────────── // CustomizationEnablement is a single explicit customization enablement decision. @@ -5767,6 +6129,273 @@ func (u AutomationRunLifecycle) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// CanvasSource identifies the explicitly installed extension or package that declares a canvas type. +type CanvasSource struct { + Value isCanvasSource +} + +// isCanvasSource is the marker interface implemented by every +// concrete variant of CanvasSource. +type isCanvasSource interface{ isCanvasSource() } + +func (*CanvasExtensionSource) isCanvasSource() {} +func (*CanvasPackageSource) isCanvasSource() {} + +// CanvasSourceUnknown carries an unrecognized CanvasSource variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. +type CanvasSourceUnknown struct { + Raw json.RawMessage +} + +func (*CanvasSourceUnknown) isCanvasSource() {} + +// UnmarshalJSON decodes the variant indicated by the "kind" discriminator. +func (u *CanvasSource) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "extension": + var value CanvasExtensionSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "package": + var value CanvasPackageSource + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &CanvasSourceUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u CanvasSource) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*CanvasSourceUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + data, err := json.Marshal(u.Value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + switch u.Value.(type) { + case *CanvasExtensionSource: + object["kind"] = json.RawMessage("\"extension\"") + case *CanvasPackageSource: + object["kind"] = json.RawMessage("\"package\"") + } + return json.Marshal(object) +} + +// CanvasTrustState is the current trust decision governing whether a canvas's declared actions may execute. +type CanvasTrustState struct { + Value isCanvasTrustState +} + +// isCanvasTrustState is the marker interface implemented by every +// concrete variant of CanvasTrustState. +type isCanvasTrustState interface{ isCanvasTrustState() } + +func (*CanvasTrustedState) isCanvasTrustState() {} +func (*CanvasPendingTrustState) isCanvasTrustState() {} +func (*CanvasBlockedTrustState) isCanvasTrustState() {} + +// CanvasTrustStateUnknown carries an unrecognized CanvasTrustState variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. +type CanvasTrustStateUnknown struct { + Raw json.RawMessage +} + +func (*CanvasTrustStateUnknown) isCanvasTrustState() {} + +// UnmarshalJSON decodes the variant indicated by the "status" discriminator. +func (u *CanvasTrustState) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "status") + if err != nil { + return err + } + switch disc { + case "trusted": + var value CanvasTrustedState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "pending": + var value CanvasPendingTrustState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "blocked": + var value CanvasBlockedTrustState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &CanvasTrustStateUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u CanvasTrustState) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*CanvasTrustStateUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + data, err := json.Marshal(u.Value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + switch u.Value.(type) { + case *CanvasTrustedState: + object["status"] = json.RawMessage("\"trusted\"") + case *CanvasPendingTrustState: + object["status"] = json.RawMessage("\"pending\"") + case *CanvasBlockedTrustState: + object["status"] = json.RawMessage("\"blocked\"") + } + return json.Marshal(object) +} + +// CanvasAvailabilityState is the current live resolution state of a canvas. +type CanvasAvailabilityState struct { + Value isCanvasAvailabilityState +} + +// isCanvasAvailabilityState is the marker interface implemented by every +// concrete variant of CanvasAvailabilityState. +type isCanvasAvailabilityState interface{ isCanvasAvailabilityState() } + +func (*CanvasUnsupportedAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasNotLoadedAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasLoadingAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasEmptyAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasReadyAvailabilityState) isCanvasAvailabilityState() {} +func (*CanvasFailedAvailabilityState) isCanvasAvailabilityState() {} + +// CanvasAvailabilityStateUnknown carries an unrecognized CanvasAvailabilityState variant — typically a discriminator value introduced by a newer protocol version. The original JSON object is preserved verbatim so that re-encoding round-trips faithfully. +type CanvasAvailabilityStateUnknown struct { + Raw json.RawMessage +} + +func (*CanvasAvailabilityStateUnknown) isCanvasAvailabilityState() {} + +// UnmarshalJSON decodes the variant indicated by the "status" discriminator. +func (u *CanvasAvailabilityState) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "status") + if err != nil { + return err + } + switch disc { + case "unsupported": + var value CanvasUnsupportedAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "notLoaded": + var value CanvasNotLoadedAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "loading": + var value CanvasLoadingAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "empty": + var value CanvasEmptyAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "ready": + var value CanvasReadyAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "failed": + var value CanvasFailedAvailabilityState + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + default: + raw := make(json.RawMessage, len(data)) + copy(raw, data) + u.Value = &CanvasAvailabilityStateUnknown{Raw: raw} + } + return nil +} + +// MarshalJSON encodes the active variant back to JSON. +func (u CanvasAvailabilityState) MarshalJSON() ([]byte, error) { + if unk, ok := u.Value.(*CanvasAvailabilityStateUnknown); ok { + if len(unk.Raw) == 0 { + return []byte("null"), nil + } + return unk.Raw, nil + } + if u.Value == nil { + return []byte("null"), nil + } + data, err := json.Marshal(u.Value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + switch u.Value.(type) { + case *CanvasUnsupportedAvailabilityState: + object["status"] = json.RawMessage("\"unsupported\"") + case *CanvasNotLoadedAvailabilityState: + object["status"] = json.RawMessage("\"notLoaded\"") + case *CanvasLoadingAvailabilityState: + object["status"] = json.RawMessage("\"loading\"") + case *CanvasEmptyAvailabilityState: + object["status"] = json.RawMessage("\"empty\"") + case *CanvasReadyAvailabilityState: + object["status"] = json.RawMessage("\"ready\"") + case *CanvasFailedAvailabilityState: + object["status"] = json.RawMessage("\"failed\"") + } + return json.Marshal(object) +} + // ChatOrigin describes how a chat came into existence. type ChatOrigin struct { Value isChatOrigin diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 456d539c..546b8583 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -6,12 +6,13 @@ package ahptypes // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.9.0" +const ProtocolVersion = "0.10.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 4faee915..3f1b9a89 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,6 +2,7 @@ "client": "go", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index bdf2a488..63e46946 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,6 +2,7 @@ "client": "kotlin", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 87c7493f..5c4b796f 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -20,10 +20,10 @@ import kotlinx.serialization.json.JsonElement * * The companion top-level functions ([rootReducer], [sessionReducer], [chatReducer], * [terminalReducer], [changesetReducer], [annotationsReducer], [resourceWatchReducer], - * [automationReducer], and [automationRunReducer]) are the canonical implementations. + * [canvasReducer], [automationReducer], and [automationRunReducer]) are the canonical implementations. * The object instances on this interface ([RootReducer], [SessionReducer], [ChatReducer], * [TerminalReducer], [ChangesetReducer], [AnnotationsReducer], [ResourceWatchReducer], - * [AutomationReducer], and [AutomationRunReducer]) wrap them for use as values where an + * [CanvasReducer], [AutomationReducer], and [AutomationRunReducer]) wrap them for use as values where an * instance is needed. */ public fun interface Reducer { @@ -72,6 +72,12 @@ public object ResourceWatchReducer : Reducer { resourceWatchReducer(state, action) } +/** Pure canvas reducer as a [Reducer] instance. Delegates to [canvasReducer]. */ +public object CanvasReducer : Reducer { + override fun reduce(state: CanvasState, action: StateAction): CanvasState = + canvasReducer(state, action) +} + /** Pure automation reducer as a [Reducer] instance. Delegates to [automationReducer]. */ public object AutomationReducer : Reducer { override fun reduce(state: AutomationState, action: StateAction): AutomationState = @@ -560,6 +566,27 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat } } + is StateActionSessionCanvasSet -> { + val canvas = action.value.canvas + val canvases = state.canvases.orEmpty() + val idx = canvases.indexOfFirst { it.resource == canvas.resource } + if (idx < 0) { + state.copy(canvases = canvases + canvas) + } else if (canvas.revision <= canvases[idx].revision) { + state + } else { + val updated = canvases.toMutableList() + updated[idx] = canvas + state.copy(canvases = updated) + } + } + + is StateActionSessionCanvasRemoved -> { + val canvases = state.canvases ?: return state + val idx = canvases.indexOfFirst { it.resource == action.value.resource } + if (idx < 0) state else state.copy(canvases = canvases.toMutableList().also { it.removeAt(idx) }) + } + is StateActionSessionChatRemoved -> { val chat = action.value.chat val idx = state.chats.indexOfFirst { it.resource == chat } @@ -1846,6 +1873,36 @@ public fun resourceWatchReducer(state: ResourceWatchState, action: StateAction): else -> state } +// ─── Canvas Reducer ───────────────────────────────────────────────────────── + +/** Pure reducer for [CanvasState]. Rejects stale or duplicate revisions. */ +public fun canvasReducer(state: CanvasState, action: StateAction): CanvasState = when (action) { + is StateActionCanvasAvailabilityChanged -> + if (action.value.revision <= state.revision) state + else state.copy(availability = action.value.availability, revision = action.value.revision) + + is StateActionCanvasTrustChanged -> + if (action.value.revision <= state.revision) state + else state.copy(trust = action.value.trust, revision = action.value.revision) + + is StateActionCanvasIncarnationChanged -> + if (action.value.revision <= state.revision) state + else state.copy( + identity = state.identity.copy(incarnation = action.value.incarnation), + revision = action.value.revision, + ) + + is StateActionCanvasTitleChanged -> + if (action.value.revision <= state.revision) state + else state.copy(title = action.value.title, revision = action.value.revision) + + is StateActionCanvasIconChanged -> + if (action.value.revision <= state.revision) state + else state.copy(icon = action.value.icon, revision = action.value.revision) + + else -> state +} + // ─── Automation Reducer ───────────────────────────────────────────────────── /** Pure reducer for [AutomationState]. Handles automation-channel action variants. */ diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index e0326f28..d6cc5577 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -125,6 +125,13 @@ value class ActionType(val rawValue: String) { val AUTOMATION_RUN_SESSION_REMOVED: ActionType = ActionType("automationRun/sessionRemoved") val AUTOMATION_RUN_PRIMARY_SESSION_CHANGED: ActionType = ActionType("automationRun/primarySessionChanged") val AUTOMATION_RUN_CANCEL_REQUESTED: ActionType = ActionType("automationRun/cancelRequested") + val SESSION_CANVAS_SET: ActionType = ActionType("session/canvasSet") + val SESSION_CANVAS_REMOVED: ActionType = ActionType("session/canvasRemoved") + val CANVAS_AVAILABILITY_CHANGED: ActionType = ActionType("canvas/availabilityChanged") + val CANVAS_TRUST_CHANGED: ActionType = ActionType("canvas/trustChanged") + val CANVAS_INCARNATION_CHANGED: ActionType = ActionType("canvas/incarnationChanged") + val CANVAS_TITLE_CHANGED: ActionType = ActionType("canvas/titleChanged") + val CANVAS_ICON_CHANGED: ActionType = ActionType("canvas/iconChanged") } } @@ -1522,6 +1529,132 @@ data class AutomationRunCancelRequestedAction( val type: ActionType ) +@Serializable +data class SessionCanvasSetAction( + val type: ActionType, + /** + * The canvas entry to add or update, matched by `resource`. + */ + val canvas: CanvasEntry +) + +@Serializable +data class SessionCanvasRemovedAction( + val type: ActionType, + /** + * Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + */ + val resource: String +) + +@Serializable +data class CanvasAvailabilityChangedAction( + val type: ActionType, + /** + * New {@link CanvasState.availability}. + */ + val availability: CanvasAvailabilityState, + /** + * The {@link CanvasState.revision} this action results in. The reducer + * MUST reject (no-op) this action if `revision` is not strictly greater + * than the canvas's current `revision` — this is how stale/out-of-order + * deliveries are consistently rejected across every canvas action, not + * just this one. + */ + val revision: Long +) + +@Serializable +data class CanvasTrustChangedAction( + val type: ActionType, + /** + * New {@link CanvasState.trust}. + */ + val trust: CanvasTrustState, + /** + * The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + */ + val revision: Long +) + +@Serializable +data class CanvasIncarnationChangedAction( + val type: ActionType, + /** + * New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + */ + val incarnation: String, + /** + * The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + */ + val revision: Long +) + +@Serializable +data class CanvasTitleChangedAction( + val type: ActionType, + /** + * New {@link CanvasState.title}. + */ + val title: String, + /** + * The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + */ + val revision: Long +) + +@Serializable(with = CanvasIconChangedActionSerializer::class) +data class CanvasIconChangedAction( + val type: ActionType, + /** + * New {@link CanvasState.icon}; `null` removes the current icon. + */ + val icon: Icon?, + /** + * The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + */ + val revision: Long +) + +@Serializable +private data class CanvasIconChangedActionWire( + val type: ActionType, + val icon: Icon?, + val revision: Long +) + +internal object CanvasIconChangedActionSerializer : KSerializer { + override val descriptor: SerialDescriptor = CanvasIconChangedActionWire.serializer().descriptor + + override fun deserialize(decoder: Decoder): CanvasIconChangedAction { + val input = decoder as? JsonDecoder + ?: error("CanvasIconChangedAction can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: throw kotlinx.serialization.SerializationException("Expected JsonObject for CanvasIconChangedAction") + if (!obj.containsKey("icon")) throw kotlinx.serialization.SerializationException("CanvasIconChangedAction: missing required field \"icon\"") + val wire = input.json.decodeFromJsonElement(CanvasIconChangedActionWire.serializer(), element) + return CanvasIconChangedAction( + type = wire.type, + icon = wire.icon, + revision = wire.revision, + ) + } + + override fun serialize(encoder: Encoder, value: CanvasIconChangedAction) { + val output = encoder as? JsonEncoder + ?: error("CanvasIconChangedAction can only be serialized to JSON") + val wire = CanvasIconChangedActionWire( + type = value.type, + icon = value.icon, + revision = value.revision, + ) + var element: JsonElement = output.json.encodeToJsonElement(CanvasIconChangedActionWire.serializer(), wire) + if (value.icon == null) element = JsonObject(element.jsonObject + ("icon" to kotlinx.serialization.json.JsonNull)) + output.encodeJsonElement(element) + } +} + // ─── Partial Summary Types ────────────────────────────────────────────────── @Serializable @@ -1675,6 +1808,13 @@ sealed interface StateAction @JvmInline value class StateActionAutomationRunSessionRemoved(val value: AutomationRunSessionRemovedAction) : StateAction @JvmInline value class StateActionAutomationRunPrimarySessionChanged(val value: AutomationRunPrimarySessionChangedAction) : StateAction @JvmInline value class StateActionAutomationRunCancelRequested(val value: AutomationRunCancelRequestedAction) : StateAction +@JvmInline value class StateActionSessionCanvasSet(val value: SessionCanvasSetAction) : StateAction +@JvmInline value class StateActionSessionCanvasRemoved(val value: SessionCanvasRemovedAction) : StateAction +@JvmInline value class StateActionCanvasAvailabilityChanged(val value: CanvasAvailabilityChangedAction) : StateAction +@JvmInline value class StateActionCanvasTrustChanged(val value: CanvasTrustChangedAction) : StateAction +@JvmInline value class StateActionCanvasIncarnationChanged(val value: CanvasIncarnationChangedAction) : StateAction +@JvmInline value class StateActionCanvasTitleChanged(val value: CanvasTitleChangedAction) : StateAction +@JvmInline value class StateActionCanvasIconChanged(val value: CanvasIconChangedAction) : StateAction @JvmInline value class StateActionUnknown(val raw: JsonObject) : StateAction internal object StateActionSerializer : KSerializer { @@ -1786,6 +1926,13 @@ internal object StateActionSerializer : KSerializer { "automationRun/sessionRemoved" -> StateActionAutomationRunSessionRemoved(input.json.decodeFromJsonElement(AutomationRunSessionRemovedAction.serializer(), element)) "automationRun/primarySessionChanged" -> StateActionAutomationRunPrimarySessionChanged(input.json.decodeFromJsonElement(AutomationRunPrimarySessionChangedAction.serializer(), element)) "automationRun/cancelRequested" -> StateActionAutomationRunCancelRequested(input.json.decodeFromJsonElement(AutomationRunCancelRequestedAction.serializer(), element)) + "session/canvasSet" -> StateActionSessionCanvasSet(input.json.decodeFromJsonElement(SessionCanvasSetAction.serializer(), element)) + "session/canvasRemoved" -> StateActionSessionCanvasRemoved(input.json.decodeFromJsonElement(SessionCanvasRemovedAction.serializer(), element)) + "canvas/availabilityChanged" -> StateActionCanvasAvailabilityChanged(input.json.decodeFromJsonElement(CanvasAvailabilityChangedAction.serializer(), element)) + "canvas/trustChanged" -> StateActionCanvasTrustChanged(input.json.decodeFromJsonElement(CanvasTrustChangedAction.serializer(), element)) + "canvas/incarnationChanged" -> StateActionCanvasIncarnationChanged(input.json.decodeFromJsonElement(CanvasIncarnationChangedAction.serializer(), element)) + "canvas/titleChanged" -> StateActionCanvasTitleChanged(input.json.decodeFromJsonElement(CanvasTitleChangedAction.serializer(), element)) + "canvas/iconChanged" -> StateActionCanvasIconChanged(input.json.decodeFromJsonElement(CanvasIconChangedAction.serializer(), element)) else -> StateActionUnknown(obj) } } @@ -1890,6 +2037,13 @@ internal object StateActionSerializer : KSerializer { is StateActionAutomationRunSessionRemoved -> output.json.encodeToJsonElement(AutomationRunSessionRemovedAction.serializer(), value.value) is StateActionAutomationRunPrimarySessionChanged -> output.json.encodeToJsonElement(AutomationRunPrimarySessionChangedAction.serializer(), value.value) is StateActionAutomationRunCancelRequested -> output.json.encodeToJsonElement(AutomationRunCancelRequestedAction.serializer(), value.value) + is StateActionSessionCanvasSet -> output.json.encodeToJsonElement(SessionCanvasSetAction.serializer(), value.value) + is StateActionSessionCanvasRemoved -> output.json.encodeToJsonElement(SessionCanvasRemovedAction.serializer(), value.value) + is StateActionCanvasAvailabilityChanged -> output.json.encodeToJsonElement(CanvasAvailabilityChangedAction.serializer(), value.value) + is StateActionCanvasTrustChanged -> output.json.encodeToJsonElement(CanvasTrustChangedAction.serializer(), value.value) + is StateActionCanvasIncarnationChanged -> output.json.encodeToJsonElement(CanvasIncarnationChangedAction.serializer(), value.value) + is StateActionCanvasTitleChanged -> output.json.encodeToJsonElement(CanvasTitleChangedAction.serializer(), value.value) + is StateActionCanvasIconChanged -> output.json.encodeToJsonElement(CanvasIconChangedAction.serializer(), value.value) is StateActionUnknown -> value.raw } output.encodeJsonElement(element) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 01fb0e15..4ef0b05e 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -390,7 +390,25 @@ data class InitializeResult( * `ahp-automations://` for {@link AutomationState}; absence means the * host does not expose an automation catalogue or automation commands. */ - val automations: AutomationCapabilities? = null + val automations: AutomationCapabilities? = null, + /** + * Host/runtime-owned local-canvas support. Presence means the SERVER + * currently has a working runtime able to serve `openCanvas` / + * `invokeCanvasAction` for at least one qualifying (explicitly installed + * and trust-eligible) extension/package source; absence means the host + * has no available canvas runtime, and clients MUST treat every canvas as + * {@link CanvasAvailabilityStatus.Unsupported} regardless of what + * {@link ClientCapabilities.canvases} declared. + * + * **Protocol version support alone is not a runtime capability**: a host + * speaking protocol `>= 0.10.0` without this field present MUST NOT be + * assumed to have a usable canvas runtime. This field — not the + * negotiated `protocolVersion` — is the authoritative signal, and is + * independent of any individual canvas's live availability + * ({@link CanvasAvailabilityState}) or trust decision + * ({@link CanvasTrustState}). + */ + val canvases: CanvasCapabilities? = null ) @Serializable @@ -408,7 +426,26 @@ data class ClientCapabilities( * capability is declared. Clients that omit it MUST treat * App-bearing tool calls as ordinary MCP tool calls. */ - val mcpApps: Map? = null + val mcpApps: Map? = null, + /** + * Client can render local canvases: `listCanvasTypes`, `openCanvas`, + * subscribe to the resulting `ahp-canvas:` channel, and drive + * `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + * `closeCanvas`. + * + * Hosts SHOULD NOT offer canvas admission to a client that omits this + * capability; such a client MUST be treated as if every canvas were + * {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + * anything about server/runtime execution trust — see + * {@link CanvasTrustStatus}, which is a separate, host-owned decision. + * + * This declares only the CLIENT's rendering capability. Protocol version + * support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + * actually has a working canvas runtime — see + * {@link InitializeResult.canvases}, the server-side counterpart, which a + * client MUST also check before treating canvases as usable. + */ + val canvases: Map? = null ) @Serializable @@ -434,6 +471,9 @@ data class AutomationCapabilities( val runHistoryLimit: Long? = null ) +@Serializable +class CanvasCapabilities + @Serializable class AutomationCreateCapability @@ -1674,6 +1714,226 @@ data class FetchAutomationRunsParams( @Serializable class FetchAutomationRunsResult +@Serializable +data class ListCanvasTypesParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Maximum number of entries to return in this page. The server SHOULD respect + * this bound but MAY return fewer entries and MAY impose its own upper cap. + * Omit to let the server choose the page size. + */ + val limit: Long? = null, + /** + * Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + * Omit to fetch the first page. Cursors are server-defined and MUST be treated + * as opaque — do not parse, modify, or persist them across connections. An + * unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + */ + val cursor: String? = null +) + +@Serializable +data class ListCanvasTypesResult( + /** + * Opaque cursor for the next page. Present when more entries exist beyond the + * returned page; absent signals the end of the collection. Pass it back as + * {@link PaginatedParams.cursor} to fetch the following page. + */ + val nextCursor: String? = null, + /** + * Discovered canvas type declarations. + */ + val types: List +) + +@Serializable +data class OpenCanvasParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + */ + val canvas: String, + /** + * Logical identity to open or re-admit. + */ + val identity: CanvasIdentityKey, + /** + * Initial (or updated, on a later effectful call) display title. + */ + val title: String, + /** + * Initial (or updated) display icon. + */ + val icon: Icon? = null, + /** + * Bounded JSON input for this open call (e.g. seed parameters the + * provider uses to initialize the canvas), opaque to the protocol. See + * {@link CanvasTypeDeclaration.openInputSchema} / + * `openInputSchemaRef` for the expected shape. The JSON-serialized value + * MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + */ + val input: JsonElement? = null, + /** + * Durable client-generated idempotency key bounding retry deduplication + * for this call within a live window; see the idempotency rules above. + * MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String +) + +@Serializable +data class OpenCanvasResult( + /** + * The catalog entry for the opened (or already-open) canvas. + */ + val canvas: CanvasEntry +) + +@Serializable +data class ResolveCanvasSourceParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class ResolveCanvasSourceResult( + /** + * Current {@link CanvasEntry.availability}. + */ + val availability: CanvasAvailabilityStatus, + /** + * Current {@link CanvasIdentity.incarnation}. + */ + val incarnation: String, + /** + * Current {@link CanvasEntry.revision}. + */ + val revision: Long, + /** + * Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + */ + val source: CanvasSourcePresentation? = null +) + +@Serializable +data class InvokeCanvasActionParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + */ + val actionId: String, + /** + * Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + * if any. The JSON-serialized value MUST NOT exceed + * `CANVAS_INPUT_MAX_LENGTH`. + */ + val input: JsonElement? = null, + /** + * Expected {@link CanvasIdentity.incarnation}. Required — see above. The + * server MUST reject the call with `Conflict` if the canvas's live + * endpoint has since been superseded, rather than deliver the call to it. + */ + val incarnation: String, + /** + * Durable client-generated idempotency key bounding retry + * deduplication for this invocation within a live window. The server is + * not required to guarantee exactly-once execution across a crash. MUST + * NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String +) + +@Serializable +data class InvokeCanvasActionResult( + /** + * The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + */ + val result: JsonElement +) + +@Serializable +data class RestartCanvasProviderParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String, + /** + * Expected current {@link CanvasIdentity.incarnation}; required — see above. + */ + val incarnation: String +) + +@Serializable +data class CloseCanvasParams( + /** + * Channel URI this command targets. + */ + val channel: String, + /** + * Optional JSON-serializable metadata associated with this request. + * Receivers MUST ignore keys they do not understand. + */ + @SerialName("_meta") + val meta: Map? = null, + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + val requestId: String, + /** + * Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + */ + val revision: Long +) + // ─── ChatSource Union ─────────────────────────────────────────────────────── @Serializable(with = ChatSourceSerializer::class) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index 9fee270b..da703e6a 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -1141,6 +1141,126 @@ enum class AutomationRunOriginKind { TRIGGER } +/** + * Discriminant for {@link CanvasSource} — what kind of package originates a + * canvas type. + */ +@Serializable(with = CanvasSourceKindSerializer::class) +@JvmInline +value class CanvasSourceKind(val rawValue: String) { + companion object { + /** + * An explicitly installed host extension. + */ + val EXTENSION: CanvasSourceKind = CanvasSourceKind("extension") + /** + * An explicitly installed package (not a host extension). + */ + val PACKAGE: CanvasSourceKind = CanvasSourceKind("package") + } +} + +internal object CanvasSourceKindSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CanvasSourceKind", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: CanvasSourceKind) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): CanvasSourceKind = + CanvasSourceKind(decoder.decodeString()) +} + +/** + * Discriminant for {@link CanvasTrustState} — whether the host currently + * permits this canvas's declared actions to execute. + * + * Trust is independent of {@link CanvasAvailabilityStatus | availability}: + * a canvas may be perfectly capable of rendering while blocked from + * executing actions, and vice versa. Trust decisions are host/runtime + * authority, not something this protocol grants. + */ +@Serializable(with = CanvasTrustStatusSerializer::class) +@JvmInline +value class CanvasTrustStatus(val rawValue: String) { + companion object { + /** + * Declared actions may be invoked. + */ + val TRUSTED: CanvasTrustStatus = CanvasTrustStatus("trusted") + /** + * A trust decision has not yet been made (e.g. first use of a new/changed source). + */ + val PENDING: CanvasTrustStatus = CanvasTrustStatus("pending") + /** + * The host has denied execution; declared actions MUST NOT be invoked. + */ + val BLOCKED: CanvasTrustStatus = CanvasTrustStatus("blocked") + } +} + +internal object CanvasTrustStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CanvasTrustStatus", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: CanvasTrustStatus) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): CanvasTrustStatus = + CanvasTrustStatus(decoder.decodeString()) +} + +/** + * Discriminant for {@link CanvasAvailabilityState} — the canvas's current + * live resolution state, independent of its durable + * {@link CanvasEntry | membership} in a session's catalog. + * + * An empty catalog membership list is not itself a close, and a canvas may + * remain a recorded member while its live availability cycles through these + * states any number of times (e.g. across provider restarts). + */ +@Serializable(with = CanvasAvailabilityStatusSerializer::class) +@JvmInline +value class CanvasAvailabilityStatus(val rawValue: String) { + companion object { + /** + * The connected client or host does not support this canvas type (e.g. + * the client omitted the `canvases` capability, or no local runtime can + * render this `canvasType`). Distinct from `blocked` trust, which is a + * policy decision rather than a capability gap. + */ + val UNSUPPORTED: CanvasAvailabilityStatus = CanvasAvailabilityStatus("unsupported") + /** + * Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + */ + val NOT_LOADED: CanvasAvailabilityStatus = CanvasAvailabilityStatus("notLoaded") + /** + * Currently resolving or (re)connecting to a live endpoint. + */ + val LOADING: CanvasAvailabilityStatus = CanvasAvailabilityStatus("loading") + /** + * Live and reachable, but the provider has not yet produced content to render. + */ + val EMPTY: CanvasAvailabilityStatus = CanvasAvailabilityStatus("empty") + /** + * Live, reachable, and has declared its current actions. + */ + val READY: CanvasAvailabilityStatus = CanvasAvailabilityStatus("ready") + /** + * The live endpoint failed to resolve, or resolution otherwise failed. + */ + val FAILED: CanvasAvailabilityStatus = CanvasAvailabilityStatus("failed") + } +} + +internal object CanvasAvailabilityStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CanvasAvailabilityStatus", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: CanvasAvailabilityStatus) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): CanvasAvailabilityStatus = + CanvasAvailabilityStatus(decoder.decodeString()) +} + // ─── State Types ──────────────────────────────────────────────────────────── @Serializable @@ -1835,6 +1955,17 @@ data class SessionState( * {@link /guide/changesets | Changesets} for an overview of the model. */ val changesets: List? = null, + /** + * Catalog of canvases opened for chats in this session. Presence is + * durable logical membership, admitted via `openCanvas` or host publication + * of a correlated, already-open native instance under that command's + * admission rules. Membership is never implied by discovery, subscription, + * source resolution, a chat's existence, or a client's earlier focus. + * Each entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the + * exact backing chat; a canvas never migrates to a different chat. See + * {@link CanvasEntry} for the full membership/availability/trust model. + */ + val canvases: List? = null, /** * Outstanding input the session is blocked on, aggregated across every chat * so a client can discover and answer it from the session channel alone, @@ -5604,6 +5735,352 @@ data class AutomationRunState( val meta: Map? = null ) +@Serializable +data class CanvasExtensionSource( + val kind: CanvasSourceKind, + /** + * Stable extension identifier (host-defined format, e.g. `publisher.name`). + * MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val extensionId: String, + /** + * Installed extension version, when known. Metadata only — not identity-bearing. + */ + val version: String? = null +) + +@Serializable +data class CanvasPackageSource( + val kind: CanvasSourceKind, + /** + * Stable, host- or package-manager-assigned unique identifier for this + * specific installed package instance/scope (opaque format). This is the + * identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val sourceId: String, + /** + * Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + */ + val packageName: String, + /** + * Installed package version, when known. Metadata only — not identity-bearing. + */ + val version: String? = null +) + +@Serializable +data class CanvasIdentityKey( + /** + * The exact backing chat this canvas belongs to. A canvas is never + * re-associated with a different chat; opening a new one for another chat + * creates a distinct canvas. + */ + val chat: String, + /** + * The extension or package that declares this canvas's type. + */ + val source: CanvasSource, + /** + * Provider-declared canvas type (host/provider-defined format). MUST NOT + * exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val canvasType: String, + /** + * Provider-chosen stable identifier for this canvas instance, scoped to + * `(chat, source, canvasType)`. Stable across reloads and host/window + * restarts for the same logical canvas. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val instanceId: String +) + +@Serializable +data class CanvasIdentity( + /** + * The exact backing chat this canvas belongs to. A canvas is never + * re-associated with a different chat; opening a new one for another chat + * creates a distinct canvas. + */ + val chat: String, + /** + * The extension or package that declares this canvas's type. + */ + val source: CanvasSource, + /** + * Provider-declared canvas type (host/provider-defined format). MUST NOT + * exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val canvasType: String, + /** + * Provider-chosen stable identifier for this canvas instance, scoped to + * `(chat, source, canvasType)`. Stable across reloads and host/window + * restarts for the same logical canvas. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val instanceId: String, + /** + * Opaque, host-generated token identifying the current generation of this + * canvas's live endpoint. The host mints a fresh token whenever a provider + * restart retires the previous live endpoint and establishes a new one for + * the same logical instance (see {@link CanvasIncarnationChangedAction | + * `canvas/incarnationChanged`}); it is not changed by a plain page reload + * or transient presentation credential renewal for the same still-live + * endpoint. + * + * `incarnation` is **opaque**: clients and hosts MUST compare it only for + * equality, never parse it, sort it, or perform arithmetic on it (e.g. it + * is not guaranteed to be numeric or monotonically increasing). The host + * MUST NOT reuse a token for this logical identity once it has been + * superseded, including across a host/process restart — if the host + * cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + * or timestamp-derived) that make accidental reuse practically + * impossible, rather than a small resettable counter. + * + * Clients and hosts use `incarnation` to reject stale callbacks and + * in-flight effects addressed to a superseded endpoint. + */ + val incarnation: String +) + +@Serializable +data class CanvasTrustedState( + val status: CanvasTrustStatus +) + +@Serializable +data class CanvasPendingTrustState( + val status: CanvasTrustStatus +) + +@Serializable +data class CanvasBlockedTrustState( + val status: CanvasTrustStatus, + /** + * Optional human-readable reason surfaced to the user. + */ + val reason: String? = null +) + +@Serializable +data class CanvasActionDeclaration( + /** + * Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + */ + val id: String, + /** + * Human-readable display name. + */ + val title: String? = null, + /** + * Description of what invoking the action does. + */ + val description: String? = null, + /** + * Inline JSON Schema for the expected `input`, when small enough to embed + * (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + * checked by {@link isCanvasSchemaWithinLimits}). Optional because some + * declared actions take no input. Mutually exclusive with + * `inputSchemaRef` — a declaration MUST supply at most one of the two. + */ + val inputSchema: JsonElement? = null, + /** + * Bounded out-of-band reference to a larger JSON Schema, used instead of + * `inputSchema` when the schema would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. AHP does not mandate a specific resolution mechanism for this + * URI (e.g. a host MAY make it `resourceRead`-able). + */ + val inputSchemaRef: String? = null +) + +@Serializable +data class CanvasUnsupportedAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasNotLoadedAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasLoadingAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasEmptyAvailabilityState( + val status: CanvasAvailabilityStatus +) + +@Serializable +data class CanvasReadyAvailabilityState( + val status: CanvasAvailabilityStatus, + /** + * Actions currently declared by the live provider (full replacement each time this state is produced). + */ + val actions: List +) + +@Serializable +data class CanvasFailedAvailabilityState( + val status: CanvasAvailabilityStatus, + /** + * Stable machine-readable and human-readable failure information. + */ + val error: ErrorInfo +) + +@Serializable +data class CanvasEntry( + /** + * Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + */ + val resource: String, + /** + * Full identity, including current incarnation. + */ + val identity: CanvasIdentity, + /** + * Human-readable display title. + */ + val title: String, + /** + * Optional display icon. + */ + val icon: Icon? = null, + /** + * Current trust decision matching {@link CanvasState.trust}. + */ + val trust: CanvasTrustState, + /** + * Current availability status matching {@link CanvasState.availability}'s discriminant. + */ + val availability: CanvasAvailabilityStatus, + /** + * Monotonically increasing counter bumped on every change to this + * canvas's state (trust, availability, or incarnation). Clients MAY use it + * to detect and reject stale reads without a full deep comparison. + * Transient presentation credential renewal alone does not require a + * revision change. + */ + val revision: Long, + /** + * Opaque host-defined summary metadata. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class CanvasState( + /** + * URI of this canvas channel. + */ + val resource: String, + /** + * Full identity, including current incarnation. + */ + val identity: CanvasIdentity, + /** + * Human-readable display title. + */ + val title: String, + /** + * Optional display icon. + */ + val icon: Icon? = null, + /** + * Current trust decision. + */ + val trust: CanvasTrustState, + /** + * Current live resolution state. + */ + val availability: CanvasAvailabilityState, + /** + * Matches {@link CanvasEntry.revision}. + */ + val revision: Long, + /** + * Opaque host-defined metadata. + */ + @SerialName("_meta") + val meta: Map? = null +) + +@Serializable +data class CanvasTypeDeclaration( + /** + * The extension or package that declares this canvas type. + */ + val source: CanvasSource, + /** + * Provider-declared canvas type (host/provider-defined format), passed as + * {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + val canvasType: String, + /** + * Human-readable display name for a canvas-type picker. + */ + val title: String, + /** + * Description of what this canvas type does. + */ + val description: String? = null, + /** + * Optional display icon. + */ + val icon: Icon? = null, + /** + * Inline JSON Schema describing the `openCanvas` `input` this type + * expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + * / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + * `openInputSchemaRef`. + */ + val openInputSchema: JsonElement? = null, + /** + * Bounded out-of-band reference to a larger open-input JSON Schema, used + * instead of `openInputSchema` when it would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. + */ + val openInputSchemaRef: String? = null, + /** + * Advisory, statically-known preview of actions this canvas type + * typically declares once opened (bounded to + * {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + * the actual invocable actions for an opened instance are always + * {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + * depend on live provider configuration) and MUST be used instead of this + * preview once the canvas is open. + */ + val declaredActions: List? = null +) + +@Serializable +data class CanvasSourcePresentation( + /** + * Ephemeral URL to the canvas's current live endpoint. Transient: MUST + * NOT be persisted (including durable canvas/session state or editor + * restoration data), written to routine logs, or treated as a stable + * identity. A host MAY embed + * short-lived, single-use credentials in it; such credentials are never + * durable authority. Renewed credentials MAY produce a different URL for + * the same incarnation and revision. Reuse is safe only while the + * credential is known to remain valid and reusable. + */ + val url: String, + /** + * Advisory expiry hint for `url` (and any embedded credential), when + * known. Omission does not imply indefinite validity or reusability, and + * an unexpired credential may still be single-use. + */ + val expiresAt: String? = null +) + // ─── Customization Enablement Union ───────────────────────────────────── /** @@ -6895,6 +7372,199 @@ internal object AutomationRunLifecycleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CanvasSource") + + override fun deserialize(decoder: Decoder): CanvasSource { + val input = decoder as? JsonDecoder + ?: error("CanvasSource can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CanvasSource") + val discriminant = (obj["kind"] as? JsonPrimitive)?.content + ?: return CanvasSourceUnknown(obj) + return when (discriminant) { + "extension" -> CanvasSourceExtension(input.json.decodeFromJsonElement(CanvasExtensionSource.serializer(), element)) + "package" -> CanvasSourcePackage(input.json.decodeFromJsonElement(CanvasPackageSource.serializer(), element)) + else -> CanvasSourceUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: CanvasSource) { + val output = encoder as? JsonEncoder + ?: error("CanvasSource can only be serialized to JSON") + val element: JsonElement = when (value) { + is CanvasSourceExtension -> output.json.encodeToJsonElement(CanvasExtensionSource.serializer(), value.value) + is CanvasSourcePackage -> output.json.encodeToJsonElement(CanvasPackageSource.serializer(), value.value) + is CanvasSourceUnknown -> value.raw + } + val encodedObject = element.jsonObject.toMutableMap() + val discriminant = when (value) { + is CanvasSourceExtension -> "extension" + is CanvasSourcePackage -> "package" + is CanvasSourceUnknown -> null + } + if (discriminant != null) encodedObject["kind"] = JsonPrimitive(discriminant) + output.encodeJsonElement(JsonObject(encodedObject)) + } +} + +@Serializable(with = CanvasTrustStateSerializer::class) +sealed interface CanvasTrustState + +@JvmInline +value class CanvasTrustStateTrusted(val value: CanvasTrustedState) : CanvasTrustState +@JvmInline +value class CanvasTrustStatePending(val value: CanvasPendingTrustState) : CanvasTrustState +@JvmInline +value class CanvasTrustStateBlocked(val value: CanvasBlockedTrustState) : CanvasTrustState +/** + * Forward-compat catch-all for unknown CanvasTrustState discriminators. + * + * Older clients may receive newer wire variants they don't recognise; capturing + * the raw `JsonObject` lets such payloads round-trip through the client unchanged. + * Reducers handle this variant conservatively on a per-union basis (typically + * as a no-op, but see `Reducers.kt` for the exact treatment). + */ +@JvmInline +value class CanvasTrustStateUnknown(val raw: JsonObject) : CanvasTrustState + +internal object CanvasTrustStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CanvasTrustState") + + override fun deserialize(decoder: Decoder): CanvasTrustState { + val input = decoder as? JsonDecoder + ?: error("CanvasTrustState can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CanvasTrustState") + val discriminant = (obj["status"] as? JsonPrimitive)?.content + ?: return CanvasTrustStateUnknown(obj) + return when (discriminant) { + "trusted" -> CanvasTrustStateTrusted(input.json.decodeFromJsonElement(CanvasTrustedState.serializer(), element)) + "pending" -> CanvasTrustStatePending(input.json.decodeFromJsonElement(CanvasPendingTrustState.serializer(), element)) + "blocked" -> CanvasTrustStateBlocked(input.json.decodeFromJsonElement(CanvasBlockedTrustState.serializer(), element)) + else -> CanvasTrustStateUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: CanvasTrustState) { + val output = encoder as? JsonEncoder + ?: error("CanvasTrustState can only be serialized to JSON") + val element: JsonElement = when (value) { + is CanvasTrustStateTrusted -> output.json.encodeToJsonElement(CanvasTrustedState.serializer(), value.value) + is CanvasTrustStatePending -> output.json.encodeToJsonElement(CanvasPendingTrustState.serializer(), value.value) + is CanvasTrustStateBlocked -> output.json.encodeToJsonElement(CanvasBlockedTrustState.serializer(), value.value) + is CanvasTrustStateUnknown -> value.raw + } + val encodedObject = element.jsonObject.toMutableMap() + val discriminant = when (value) { + is CanvasTrustStateTrusted -> "trusted" + is CanvasTrustStatePending -> "pending" + is CanvasTrustStateBlocked -> "blocked" + is CanvasTrustStateUnknown -> null + } + if (discriminant != null) encodedObject["status"] = JsonPrimitive(discriminant) + output.encodeJsonElement(JsonObject(encodedObject)) + } +} + +@Serializable(with = CanvasAvailabilityStateSerializer::class) +sealed interface CanvasAvailabilityState + +@JvmInline +value class CanvasAvailabilityStateUnsupported(val value: CanvasUnsupportedAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateNotLoaded(val value: CanvasNotLoadedAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateLoading(val value: CanvasLoadingAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateEmpty(val value: CanvasEmptyAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateReady(val value: CanvasReadyAvailabilityState) : CanvasAvailabilityState +@JvmInline +value class CanvasAvailabilityStateFailed(val value: CanvasFailedAvailabilityState) : CanvasAvailabilityState +/** + * Forward-compat catch-all for unknown CanvasAvailabilityState discriminators. + * + * Older clients may receive newer wire variants they don't recognise; capturing + * the raw `JsonObject` lets such payloads round-trip through the client unchanged. + * Reducers handle this variant conservatively on a per-union basis (typically + * as a no-op, but see `Reducers.kt` for the exact treatment). + */ +@JvmInline +value class CanvasAvailabilityStateUnknown(val raw: JsonObject) : CanvasAvailabilityState + +internal object CanvasAvailabilityStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CanvasAvailabilityState") + + override fun deserialize(decoder: Decoder): CanvasAvailabilityState { + val input = decoder as? JsonDecoder + ?: error("CanvasAvailabilityState can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CanvasAvailabilityState") + val discriminant = (obj["status"] as? JsonPrimitive)?.content + ?: return CanvasAvailabilityStateUnknown(obj) + return when (discriminant) { + "unsupported" -> CanvasAvailabilityStateUnsupported(input.json.decodeFromJsonElement(CanvasUnsupportedAvailabilityState.serializer(), element)) + "notLoaded" -> CanvasAvailabilityStateNotLoaded(input.json.decodeFromJsonElement(CanvasNotLoadedAvailabilityState.serializer(), element)) + "loading" -> CanvasAvailabilityStateLoading(input.json.decodeFromJsonElement(CanvasLoadingAvailabilityState.serializer(), element)) + "empty" -> CanvasAvailabilityStateEmpty(input.json.decodeFromJsonElement(CanvasEmptyAvailabilityState.serializer(), element)) + "ready" -> CanvasAvailabilityStateReady(input.json.decodeFromJsonElement(CanvasReadyAvailabilityState.serializer(), element)) + "failed" -> CanvasAvailabilityStateFailed(input.json.decodeFromJsonElement(CanvasFailedAvailabilityState.serializer(), element)) + else -> CanvasAvailabilityStateUnknown(obj) + } + } + + override fun serialize(encoder: Encoder, value: CanvasAvailabilityState) { + val output = encoder as? JsonEncoder + ?: error("CanvasAvailabilityState can only be serialized to JSON") + val element: JsonElement = when (value) { + is CanvasAvailabilityStateUnsupported -> output.json.encodeToJsonElement(CanvasUnsupportedAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateNotLoaded -> output.json.encodeToJsonElement(CanvasNotLoadedAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateLoading -> output.json.encodeToJsonElement(CanvasLoadingAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateEmpty -> output.json.encodeToJsonElement(CanvasEmptyAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateReady -> output.json.encodeToJsonElement(CanvasReadyAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateFailed -> output.json.encodeToJsonElement(CanvasFailedAvailabilityState.serializer(), value.value) + is CanvasAvailabilityStateUnknown -> value.raw + } + val encodedObject = element.jsonObject.toMutableMap() + val discriminant = when (value) { + is CanvasAvailabilityStateUnsupported -> "unsupported" + is CanvasAvailabilityStateNotLoaded -> "notLoaded" + is CanvasAvailabilityStateLoading -> "loading" + is CanvasAvailabilityStateEmpty -> "empty" + is CanvasAvailabilityStateReady -> "ready" + is CanvasAvailabilityStateFailed -> "failed" + is CanvasAvailabilityStateUnknown -> null + } + if (discriminant != null) encodedObject["status"] = JsonPrimitive(discriminant) + output.encodeJsonElement(JsonObject(encodedObject)) + } +} + @Serializable(with = ToolResultContentSerializer::class) sealed interface ToolResultContent { @JvmInline value class Text(val value: ToolResultTextContent) : ToolResultContent diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index 31c2b348..d4da3055 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.9.0" +public const val PROTOCOL_VERSION: String = "0.10.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,6 +16,7 @@ public const val PROTOCOL_VERSION: String = "0.9.0" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt index c3f4675e..c8d98aba 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt @@ -2,6 +2,7 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.ChatState import com.microsoft.agenthostprotocol.generated.ChangesetState +import com.microsoft.agenthostprotocol.generated.CanvasState import com.microsoft.agenthostprotocol.generated.AnnotationsState import com.microsoft.agenthostprotocol.generated.AutomationState import com.microsoft.agenthostprotocol.generated.AutomationRunState @@ -200,6 +201,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 + }, + ) + "automation" -> compareFixture( file = file, initial = initial, diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/StateActionTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/StateActionTest.kt index d35ae644..26030f7b 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/StateActionTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/StateActionTest.kt @@ -3,21 +3,26 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.ActionEnvelope import com.microsoft.agenthostprotocol.generated.ActionOrigin import com.microsoft.agenthostprotocol.generated.ActionType +import com.microsoft.agenthostprotocol.generated.CanvasIconChangedAction import com.microsoft.agenthostprotocol.generated.ChangesetStatus import com.microsoft.agenthostprotocol.generated.PartialSessionSummary import com.microsoft.agenthostprotocol.generated.RootAgentsChangedAction import com.microsoft.agenthostprotocol.generated.SessionStatus import com.microsoft.agenthostprotocol.generated.StateAction import com.microsoft.agenthostprotocol.generated.StateActionChangesetStatusChanged +import com.microsoft.agenthostprotocol.generated.StateActionCanvasIconChanged import com.microsoft.agenthostprotocol.generated.StateActionRootAgentsChanged import com.microsoft.agenthostprotocol.generated.StateActionSessionTitleChanged import com.microsoft.agenthostprotocol.generated.StateActionUnknown import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.SerializationException import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertFailsWith /** * Tests for [StateAction] and the [ActionEnvelope] wrapper. Critical for @@ -63,6 +68,44 @@ class StateActionTest { assertEquals(json.parseToJsonElement(futureWire).jsonObject, reTree) } + @Test + fun `canvas iconChanged direct payload preserves required null and rejects omission`() { + val action = CanvasIconChangedAction( + type = ActionType.CANVAS_ICON_CHANGED, + icon = null, + revision = 3, + ) + val encoded = json.encodeToString(CanvasIconChangedAction.serializer(), action) + assertEquals(JsonNull, json.parseToJsonElement(encoded).jsonObject["icon"]) + + assertFailsWith { + json.decodeFromString( + CanvasIconChangedAction.serializer(), + """{"type":"canvas/iconChanged","revision":3}""", + ) + } + } + + @Test + fun `canvas iconChanged StateAction preserves required null and rejects omission`() { + val action: StateAction = StateActionCanvasIconChanged( + CanvasIconChangedAction( + type = ActionType.CANVAS_ICON_CHANGED, + icon = null, + revision = 3, + ), + ) + val encoded = json.encodeToString(StateAction.serializer(), action) + assertEquals(JsonNull, json.parseToJsonElement(encoded).jsonObject["icon"]) + + assertFailsWith { + json.decodeFromString( + StateAction.serializer(), + """{"type":"canvas/iconChanged","revision":3}""", + ) + } + } + @Test fun `action without type discriminator decodes to StateActionUnknown without throwing`() { // Symmetric with the state-channel `XUnknown` variants: a payload diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 05dba2c2..c6e3bcc9 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -15,10 +15,11 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, - Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, - ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, - ChatSummary, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, - ErrorResponsePart, McpAuthRequirement, McpServerState, Message, ModelSelection, + CanvasAvailabilityState, CanvasEntry, CanvasTrustState, Changeset, ChangesetFile, + ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, + ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, + ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, + ErrorResponsePart, Icon, McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, @@ -126,6 +127,13 @@ pub enum ActionType { AutomationRunSessionRemoved, AutomationRunPrimarySessionChanged, AutomationRunCancelRequested, + SessionCanvasSet, + SessionCanvasRemoved, + CanvasAvailabilityChanged, + CanvasTrustChanged, + CanvasIncarnationChanged, + CanvasTitleChanged, + CanvasIconChanged, /// Unknown raw value from a newer protocol version, preserved verbatim. Unknown(String), } @@ -292,6 +300,15 @@ impl serde::Serialize for ActionType { Self::AutomationRunCancelRequested => { serializer.serialize_str("automationRun/cancelRequested") } + Self::SessionCanvasSet => serializer.serialize_str("session/canvasSet"), + Self::SessionCanvasRemoved => serializer.serialize_str("session/canvasRemoved"), + Self::CanvasAvailabilityChanged => { + serializer.serialize_str("canvas/availabilityChanged") + } + Self::CanvasTrustChanged => serializer.serialize_str("canvas/trustChanged"), + Self::CanvasIncarnationChanged => serializer.serialize_str("canvas/incarnationChanged"), + Self::CanvasTitleChanged => serializer.serialize_str("canvas/titleChanged"), + Self::CanvasIconChanged => serializer.serialize_str("canvas/iconChanged"), Self::Unknown(value) => serializer.serialize_str(value), } } @@ -400,6 +417,13 @@ impl<'de> serde::Deserialize<'de> for ActionType { "automationRun/sessionRemoved" => Self::AutomationRunSessionRemoved, "automationRun/primarySessionChanged" => Self::AutomationRunPrimarySessionChanged, "automationRun/cancelRequested" => Self::AutomationRunCancelRequested, + "session/canvasSet" => Self::SessionCanvasSet, + "session/canvasRemoved" => Self::SessionCanvasRemoved, + "canvas/availabilityChanged" => Self::CanvasAvailabilityChanged, + "canvas/trustChanged" => Self::CanvasTrustChanged, + "canvas/incarnationChanged" => Self::CanvasIncarnationChanged, + "canvas/titleChanged" => Self::CanvasTitleChanged, + "canvas/iconChanged" => Self::CanvasIconChanged, _ => Self::Unknown(raw), }) } @@ -437,6 +461,14 @@ pub struct ActionEnvelope { // ─── Action Payloads ───────────────────────────────────────────────── +fn deserialize_required_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// Fired when available agent backends or their models change. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2146,6 +2178,119 @@ pub struct AutomationRunPrimarySessionChangedAction { #[serde(rename_all = "camelCase")] pub struct AutomationRunCancelRequestedAction {} +/// A canvas was admitted (opened) or its catalog entry changed. +/// +/// Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the +/// server dispatches this with the full entry to record a newly opened +/// canvas, or to republish it after a trust/availability/incarnation change +/// so subscribers following only the session channel stay in sync with +/// {@link CanvasState}. Never client-dispatchable: admission is through +/// `openCanvas` or host publication of a correlated, already-open native +/// instance under that command's admission rules. Both paths MUST use the +/// same singular identity-to-resource binding; repeated native observations +/// MUST NOT create a second entry. A stale/out-of-order delivery +/// (`canvas.revision` not strictly greater than the currently-recorded entry's +/// revision) MUST be rejected (no-op) rather than overwrite a newer entry with +/// older data. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCanvasSetAction { + /// The canvas entry to add or update, matched by `resource`. + pub canvas: CanvasEntry, +} + +/// A canvas was logically closed. +/// +/// Remove semantics keyed by `resource`: an unknown URI is a no-op. This +/// represents durable membership removal, not a client hiding a local +/// tab/view — see `closeCanvas`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCanvasRemovedAction { + /// Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + pub resource: Uri, +} + +/// Replaces the canvas's live resolution state. +/// +/// Dispatched by the host on every availability transition, including +/// initial resolution after admission by `openCanvas` or a correlated native +/// open, provider restart, and endpoint failure/recovery. A client-local page +/// reload or transient presentation credential renewal alone does not require +/// this action or a revision change. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasAvailabilityChangedAction { + /// New {@link CanvasState.availability}. + pub availability: CanvasAvailabilityState, + /// The {@link CanvasState.revision} this action results in. The reducer + /// MUST reject (no-op) this action if `revision` is not strictly greater + /// than the canvas's current `revision` — this is how stale/out-of-order + /// deliveries are consistently rejected across every canvas action, not + /// just this one. + pub revision: i64, +} + +/// Replaces the canvas's trust decision. +/// +/// Dispatched by the host whenever the execution-trust decision for this +/// canvas's declared actions changes (e.g. a pending decision resolves, or an +/// administrator revokes a previously trusted source). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTrustChangedAction { + /// New {@link CanvasState.trust}. + pub trust: CanvasTrustState, + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + pub revision: i64, +} + +/// Records that the canvas's live endpoint was replaced by a fresh one for +/// the same logical instance (e.g. the owning provider restarted). +/// +/// Renewing transient presentation credentials for the same live endpoint is +/// not endpoint replacement and MUST NOT trigger this action. +/// +/// The host MUST dispatch {@link CanvasAvailabilityChangedAction} to +/// transition through `notLoaded`/`loading` around this change. Receivers +/// MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed +/// callbacks addressed to a superseded `incarnation` — because `incarnation` +/// is opaque (see {@link CanvasIdentity.incarnation}), that rejection is +/// driven by the accompanying `revision` bump here, not by comparing +/// `incarnation` values for order. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasIncarnationChangedAction { + /// New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + pub incarnation: String, + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + pub revision: i64, +} + +/// Replaces the canvas's display title. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTitleChangedAction { + /// New {@link CanvasState.title}. + pub title: String, + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + pub revision: i64, +} + +/// Replaces or removes the canvas's display icon. +/// +/// This is presentation metadata only. It does not replace the live endpoint, +/// change the canvas incarnation, or replay any canvas effect. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasIconChangedAction { + /// New {@link CanvasState.icon}; `null` removes the current icon. + #[serde(deserialize_with = "deserialize_required_nullable")] + pub icon: Option, + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + pub revision: i64, +} + // ─── Partial Summaries ──────────────────────────────────────────────── /// Partial equivalent of ChatSummary — every field is optional for delta updates. @@ -2186,7 +2331,7 @@ pub struct PartialChatSummary { // ─── StateAction Union ─────────────────────────────────────────────── /// Discriminated union of every state action. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type")] pub enum StateAction { #[serde(rename = "root/agentsChanged")] @@ -2381,8 +2526,466 @@ pub enum StateAction { AutomationRunPrimarySessionChanged(AutomationRunPrimarySessionChangedAction), #[serde(rename = "automationRun/cancelRequested")] AutomationRunCancelRequested(AutomationRunCancelRequestedAction), + #[serde(rename = "session/canvasSet")] + SessionCanvasSet(SessionCanvasSetAction), + #[serde(rename = "session/canvasRemoved")] + SessionCanvasRemoved(SessionCanvasRemovedAction), + #[serde(rename = "canvas/availabilityChanged")] + CanvasAvailabilityChanged(Box), + #[serde(rename = "canvas/trustChanged")] + CanvasTrustChanged(CanvasTrustChangedAction), + #[serde(rename = "canvas/incarnationChanged")] + CanvasIncarnationChanged(CanvasIncarnationChangedAction), + #[serde(rename = "canvas/titleChanged")] + CanvasTitleChanged(CanvasTitleChangedAction), + #[serde(rename = "canvas/iconChanged")] + CanvasIconChanged(CanvasIconChangedAction), /// 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), } + +impl<'de> Deserialize<'de> for StateAction { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("type").and_then(serde_json::Value::as_str); + match discriminator { + Some("root/agentsChanged") => serde_json::from_value::(raw) + .map(Self::RootAgentsChanged) + .map_err(serde::de::Error::custom), + Some("root/activeSessionsChanged") => { + serde_json::from_value::(raw) + .map(Self::RootActiveSessionsChanged) + .map_err(serde::de::Error::custom) + } + Some("root/configChanged") => serde_json::from_value::(raw) + .map(Self::RootConfigChanged) + .map_err(serde::de::Error::custom), + Some("session/ready") => serde_json::from_value::(raw) + .map(Self::SessionReady) + .map_err(serde::de::Error::custom), + Some("session/creationFailed") => { + serde_json::from_value::(raw) + .map(Self::SessionCreationFailed) + .map_err(serde::de::Error::custom) + } + Some("session/chatAdded") => serde_json::from_value::(raw) + .map(Self::SessionChatAdded) + .map_err(serde::de::Error::custom), + Some("session/chatRemoved") => serde_json::from_value::(raw) + .map(Self::SessionChatRemoved) + .map_err(serde::de::Error::custom), + Some("session/chatUpdated") => serde_json::from_value::(raw) + .map(Self::SessionChatUpdated) + .map_err(serde::de::Error::custom), + Some("session/defaultChatChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionDefaultChatChanged) + .map_err(serde::de::Error::custom) + } + Some("chat/turnStarted") => serde_json::from_value::(raw) + .map(Self::ChatTurnStarted) + .map_err(serde::de::Error::custom), + Some("chat/delta") => serde_json::from_value::(raw) + .map(Self::ChatDelta) + .map_err(serde::de::Error::custom), + Some("chat/responsePart") => serde_json::from_value::(raw) + .map(Self::ChatResponsePart) + .map_err(serde::de::Error::custom), + Some("chat/toolCallStart") => serde_json::from_value::(raw) + .map(Self::ChatToolCallStart) + .map_err(serde::de::Error::custom), + Some("chat/toolCallDelta") => serde_json::from_value::(raw) + .map(Self::ChatToolCallDelta) + .map_err(serde::de::Error::custom), + Some("chat/toolCallReady") => serde_json::from_value::(raw) + .map(Self::ChatToolCallReady) + .map_err(serde::de::Error::custom), + Some("chat/toolCallConfirmed") => { + serde_json::from_value::(raw) + .map(Self::ChatToolCallConfirmed) + .map_err(serde::de::Error::custom) + } + Some("chat/toolCallComplete") => { + serde_json::from_value::(raw) + .map(Self::ChatToolCallComplete) + .map_err(serde::de::Error::custom) + } + Some("chat/toolCallResultConfirmed") => { + serde_json::from_value::(raw) + .map(Self::ChatToolCallResultConfirmed) + .map_err(serde::de::Error::custom) + } + Some("chat/toolCallContentChanged") => { + serde_json::from_value::(raw) + .map(Self::ChatToolCallContentChanged) + .map_err(serde::de::Error::custom) + } + Some("chat/toolCallAuthRequired") => { + serde_json::from_value::(raw) + .map(Self::ChatToolCallAuthRequired) + .map_err(serde::de::Error::custom) + } + Some("chat/toolCallAuthResolved") => { + serde_json::from_value::(raw) + .map(Self::ChatToolCallAuthResolved) + .map_err(serde::de::Error::custom) + } + Some("chat/turnComplete") => serde_json::from_value::(raw) + .map(Self::ChatTurnComplete) + .map_err(serde::de::Error::custom), + Some("chat/turnCancelled") => serde_json::from_value::(raw) + .map(Self::ChatTurnCancelled) + .map_err(serde::de::Error::custom), + Some("chat/error") => serde_json::from_value::(raw) + .map(Self::ChatError) + .map_err(serde::de::Error::custom), + Some("chat/turnResume") => serde_json::from_value::(raw) + .map(Self::ChatTurnResume) + .map_err(serde::de::Error::custom), + Some("chat/activityChanged") => { + serde_json::from_value::(raw) + .map(Self::ChatActivityChanged) + .map_err(serde::de::Error::custom) + } + Some("session/titleChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionTitleChanged) + .map_err(serde::de::Error::custom) + } + Some("chat/usage") => serde_json::from_value::(raw) + .map(Self::ChatUsage) + .map_err(serde::de::Error::custom), + Some("chat/reasoning") => serde_json::from_value::(raw) + .map(Self::ChatReasoning) + .map_err(serde::de::Error::custom), + Some("session/isReadChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionIsReadChanged) + .map_err(serde::de::Error::custom) + } + Some("session/isArchivedChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionIsArchivedChanged) + .map_err(serde::de::Error::custom) + } + Some("session/activityChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionActivityChanged) + .map_err(serde::de::Error::custom) + } + Some("session/changesetsChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionChangesetsChanged) + .map_err(serde::de::Error::custom) + } + Some("session/serverToolsChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionServerToolsChanged) + .map_err(serde::de::Error::custom) + } + Some("session/activeClientSet") => { + serde_json::from_value::(raw) + .map(Self::SessionActiveClientSet) + .map_err(serde::de::Error::custom) + } + Some("session/activeClientRemoved") => { + serde_json::from_value::(raw) + .map(Self::SessionActiveClientRemoved) + .map_err(serde::de::Error::custom) + } + Some("session/workingDirectorySet") => { + serde_json::from_value::(raw) + .map(Self::SessionWorkingDirectorySet) + .map_err(serde::de::Error::custom) + } + Some("session/workingDirectoryRemoved") => { + serde_json::from_value::(raw) + .map(Self::SessionWorkingDirectoryRemoved) + .map_err(serde::de::Error::custom) + } + Some("session/workingDirectoryReplaced") => { + serde_json::from_value::(raw) + .map(Self::SessionWorkingDirectoryReplaced) + .map_err(serde::de::Error::custom) + } + Some("chat/workingDirectorySet") => { + serde_json::from_value::(raw) + .map(Self::ChatWorkingDirectorySet) + .map_err(serde::de::Error::custom) + } + Some("chat/workingDirectoryRemoved") => { + serde_json::from_value::(raw) + .map(Self::ChatWorkingDirectoryRemoved) + .map_err(serde::de::Error::custom) + } + Some("session/inputNeededSet") => { + serde_json::from_value::(raw) + .map(|value| Self::SessionInputNeededSet(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("session/inputNeededRemoved") => { + serde_json::from_value::(raw) + .map(Self::SessionInputNeededRemoved) + .map_err(serde::de::Error::custom) + } + Some("chat/pendingMessageSet") => { + serde_json::from_value::(raw) + .map(Self::ChatPendingMessageSet) + .map_err(serde::de::Error::custom) + } + Some("chat/pendingMessageRemoved") => { + serde_json::from_value::(raw) + .map(Self::ChatPendingMessageRemoved) + .map_err(serde::de::Error::custom) + } + Some("chat/queuedMessagesReordered") => { + serde_json::from_value::(raw) + .map(Self::ChatQueuedMessagesReordered) + .map_err(serde::de::Error::custom) + } + Some("chat/draftChanged") => serde_json::from_value::(raw) + .map(Self::ChatDraftChanged) + .map_err(serde::de::Error::custom), + Some("chat/inputRequested") => serde_json::from_value::(raw) + .map(Self::ChatInputRequested) + .map_err(serde::de::Error::custom), + Some("chat/inputAnswerChanged") => { + serde_json::from_value::(raw) + .map(Self::ChatInputAnswerChanged) + .map_err(serde::de::Error::custom) + } + Some("chat/inputCompleted") => serde_json::from_value::(raw) + .map(Self::ChatInputCompleted) + .map_err(serde::de::Error::custom), + Some("session/customizationsChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionCustomizationsChanged) + .map_err(serde::de::Error::custom) + } + Some("session/customizationToggled") => { + serde_json::from_value::(raw) + .map(Self::SessionCustomizationToggled) + .map_err(serde::de::Error::custom) + } + Some("session/customizationUpdated") => { + serde_json::from_value::(raw) + .map(|value| Self::SessionCustomizationUpdated(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("session/customizationRemoved") => { + serde_json::from_value::(raw) + .map(Self::SessionCustomizationRemoved) + .map_err(serde::de::Error::custom) + } + Some("session/mcpServerStateChanged") => { + serde_json::from_value::(raw) + .map(|value| Self::SessionMcpServerStateChanged(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("session/mcpServerStartRequested") => { + serde_json::from_value::(raw) + .map(Self::SessionMcpServerStartRequested) + .map_err(serde::de::Error::custom) + } + Some("session/mcpServerStopRequested") => { + serde_json::from_value::(raw) + .map(Self::SessionMcpServerStopRequested) + .map_err(serde::de::Error::custom) + } + Some("chat/truncated") => serde_json::from_value::(raw) + .map(Self::ChatTruncated) + .map_err(serde::de::Error::custom), + Some("chat/turnsLoaded") => serde_json::from_value::(raw) + .map(Self::ChatTurnsLoaded) + .map_err(serde::de::Error::custom), + Some("session/configChanged") => { + serde_json::from_value::(raw) + .map(Self::SessionConfigChanged) + .map_err(serde::de::Error::custom) + } + Some("session/metaChanged") => serde_json::from_value::(raw) + .map(Self::SessionMetaChanged) + .map_err(serde::de::Error::custom), + Some("changeset/statusChanged") => { + serde_json::from_value::(raw) + .map(Self::ChangesetStatusChanged) + .map_err(serde::de::Error::custom) + } + Some("changeset/fileSet") => serde_json::from_value::(raw) + .map(Self::ChangesetFileSet) + .map_err(serde::de::Error::custom), + Some("changeset/fileRemoved") => { + serde_json::from_value::(raw) + .map(Self::ChangesetFileRemoved) + .map_err(serde::de::Error::custom) + } + Some("changeset/filesReviewChanged") => { + serde_json::from_value::(raw) + .map(Self::ChangesetFilesReviewChanged) + .map_err(serde::de::Error::custom) + } + Some("changeset/contentChanged") => { + serde_json::from_value::(raw) + .map(|value| Self::ChangesetContentChanged(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("changeset/operationsChanged") => { + serde_json::from_value::(raw) + .map(Self::ChangesetOperationsChanged) + .map_err(serde::de::Error::custom) + } + Some("changeset/operationStatusChanged") => { + serde_json::from_value::(raw) + .map(Self::ChangesetOperationStatusChanged) + .map_err(serde::de::Error::custom) + } + Some("changeset/cleared") => serde_json::from_value::(raw) + .map(Self::ChangesetCleared) + .map_err(serde::de::Error::custom), + Some("annotations/set") => serde_json::from_value::(raw) + .map(Self::AnnotationsSet) + .map_err(serde::de::Error::custom), + Some("annotations/updated") => serde_json::from_value::(raw) + .map(Self::AnnotationsUpdated) + .map_err(serde::de::Error::custom), + Some("annotations/removed") => serde_json::from_value::(raw) + .map(Self::AnnotationsRemoved) + .map_err(serde::de::Error::custom), + Some("annotations/entrySet") => { + serde_json::from_value::(raw) + .map(Self::AnnotationsEntrySet) + .map_err(serde::de::Error::custom) + } + Some("annotations/entryRemoved") => { + serde_json::from_value::(raw) + .map(Self::AnnotationsEntryRemoved) + .map_err(serde::de::Error::custom) + } + Some("root/terminalsChanged") => { + serde_json::from_value::(raw) + .map(Self::RootTerminalsChanged) + .map_err(serde::de::Error::custom) + } + Some("terminal/data") => serde_json::from_value::(raw) + .map(Self::TerminalData) + .map_err(serde::de::Error::custom), + Some("terminal/input") => serde_json::from_value::(raw) + .map(Self::TerminalInput) + .map_err(serde::de::Error::custom), + Some("terminal/resized") => serde_json::from_value::(raw) + .map(Self::TerminalResized) + .map_err(serde::de::Error::custom), + Some("terminal/claimed") => serde_json::from_value::(raw) + .map(Self::TerminalClaimed) + .map_err(serde::de::Error::custom), + Some("terminal/titleChanged") => { + serde_json::from_value::(raw) + .map(Self::TerminalTitleChanged) + .map_err(serde::de::Error::custom) + } + Some("terminal/cwdChanged") => serde_json::from_value::(raw) + .map(Self::TerminalCwdChanged) + .map_err(serde::de::Error::custom), + Some("terminal/exited") => serde_json::from_value::(raw) + .map(Self::TerminalExited) + .map_err(serde::de::Error::custom), + Some("terminal/cleared") => serde_json::from_value::(raw) + .map(Self::TerminalCleared) + .map_err(serde::de::Error::custom), + Some("terminal/commandDetectionAvailable") => { + serde_json::from_value::(raw) + .map(Self::TerminalCommandDetectionAvailable) + .map_err(serde::de::Error::custom) + } + Some("terminal/commandExecuted") => { + serde_json::from_value::(raw) + .map(Self::TerminalCommandExecuted) + .map_err(serde::de::Error::custom) + } + Some("terminal/commandFinished") => { + serde_json::from_value::(raw) + .map(Self::TerminalCommandFinished) + .map_err(serde::de::Error::custom) + } + Some("resourceWatch/changed") => { + serde_json::from_value::(raw) + .map(Self::ResourceWatchChanged) + .map_err(serde::de::Error::custom) + } + Some("automation/createRequested") => { + serde_json::from_value::(raw) + .map(|value| Self::AutomationCreateRequested(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("automation/updateRequested") => { + serde_json::from_value::(raw) + .map(|value| Self::AutomationUpdateRequested(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("automation/set") => serde_json::from_value::(raw) + .map(|value| Self::AutomationSet(Box::new(value))) + .map_err(serde::de::Error::custom), + Some("automation/removed") => serde_json::from_value::(raw) + .map(Self::AutomationRemoved) + .map_err(serde::de::Error::custom), + Some("automationRun/lifecycleChanged") => { + serde_json::from_value::(raw) + .map(|value| Self::AutomationRunLifecycleChanged(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("automationRun/sessionSet") => { + serde_json::from_value::(raw) + .map(Self::AutomationRunSessionSet) + .map_err(serde::de::Error::custom) + } + Some("automationRun/sessionRemoved") => { + serde_json::from_value::(raw) + .map(Self::AutomationRunSessionRemoved) + .map_err(serde::de::Error::custom) + } + Some("automationRun/primarySessionChanged") => { + serde_json::from_value::(raw) + .map(Self::AutomationRunPrimarySessionChanged) + .map_err(serde::de::Error::custom) + } + Some("automationRun/cancelRequested") => { + serde_json::from_value::(raw) + .map(Self::AutomationRunCancelRequested) + .map_err(serde::de::Error::custom) + } + Some("session/canvasSet") => serde_json::from_value::(raw) + .map(Self::SessionCanvasSet) + .map_err(serde::de::Error::custom), + Some("session/canvasRemoved") => { + serde_json::from_value::(raw) + .map(Self::SessionCanvasRemoved) + .map_err(serde::de::Error::custom) + } + Some("canvas/availabilityChanged") => { + serde_json::from_value::(raw) + .map(|value| Self::CanvasAvailabilityChanged(Box::new(value))) + .map_err(serde::de::Error::custom) + } + Some("canvas/trustChanged") => serde_json::from_value::(raw) + .map(Self::CanvasTrustChanged) + .map_err(serde::de::Error::custom), + Some("canvas/incarnationChanged") => { + serde_json::from_value::(raw) + .map(Self::CanvasIncarnationChanged) + .map_err(serde::de::Error::custom) + } + Some("canvas/titleChanged") => serde_json::from_value::(raw) + .map(Self::CanvasTitleChanged) + .map_err(serde::de::Error::custom), + Some("canvas/iconChanged") => serde_json::from_value::(raw) + .map(Self::CanvasIconChanged) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index e3dfe255..23747d45 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -16,9 +16,11 @@ use crate::actions::{ActionEnvelope, StateAction}; #[allow(unused_imports)] use crate::state::{ AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, - AutomationTrigger, AutomationTriggerDefinition, ContentRef, Message, MessageAttachment, - ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, - Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn, + AutomationTrigger, AutomationTriggerDefinition, CanvasAvailabilityStatus, CanvasEntry, + CanvasIdentityKey, CanvasSourcePresentation, CanvasTypeDeclaration, ContentRef, Icon, Message, + MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, + SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, + Turn, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -292,6 +294,23 @@ pub struct InitializeResult { /// host does not expose an automation catalogue or automation commands. #[serde(default, skip_serializing_if = "Option::is_none")] pub automations: Option, + /// Host/runtime-owned local-canvas support. Presence means the SERVER + /// currently has a working runtime able to serve `openCanvas` / + /// `invokeCanvasAction` for at least one qualifying (explicitly installed + /// and trust-eligible) extension/package source; absence means the host + /// has no available canvas runtime, and clients MUST treat every canvas as + /// {@link CanvasAvailabilityStatus.Unsupported} regardless of what + /// {@link ClientCapabilities.canvases} declared. + /// + /// **Protocol version support alone is not a runtime capability**: a host + /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// assumed to have a usable canvas runtime. This field — not the + /// negotiated `protocolVersion` — is the authoritative signal, and is + /// independent of any individual canvas's live availability + /// ({@link CanvasAvailabilityState}) or trust decision + /// ({@link CanvasTrustState}). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option, } /// Optional capabilities a client declares during `initialize`. @@ -315,6 +334,24 @@ pub struct ClientCapabilities { /// App-bearing tool calls as ordinary MCP tool calls. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp_apps: Option, + /// Client can render local canvases: `listCanvasTypes`, `openCanvas`, + /// subscribe to the resulting `ahp-canvas:` channel, and drive + /// `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + /// `closeCanvas`. + /// + /// Hosts SHOULD NOT offer canvas admission to a client that omits this + /// capability; such a client MUST be treated as if every canvas were + /// {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + /// anything about server/runtime execution trust — see + /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. + /// + /// This declares only the CLIENT's rendering capability. Protocol version + /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// actually has a working canvas runtime — see + /// {@link InitializeResult.canvases}, the server-side counterpart, which a + /// client MUST also check before treating canvases as usable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option, } /// Automation features supported by this host authority. @@ -346,6 +383,13 @@ pub struct AutomationCapabilities { pub run_history_limit: Option, } +/// Local-canvas runtime features supported by this host authority. The empty +/// object means "supported" — see {@link InitializeResult.canvases} for what +/// presence/absence of this field itself means. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCapabilities {} + /// Presence capability for {@link AutomationCreateRequestedAction | /// `automation/createRequested`}. /// @@ -1676,10 +1720,341 @@ pub struct FetchAutomationRunsParams { #[serde(rename_all = "camelCase")] pub struct FetchAutomationRunsResult {} +/// Discovers canvas TYPES currently available to open for one exact backing +/// chat. +/// +/// This is a **pure read/browse** operation: it MUST NOT execute or start a +/// provider, or open, materialize, or otherwise admit any canvas. See +/// `openCanvas` for the admission rules, including publication of an +/// already-open native instance. This catalogue is unrelated to +/// {@link SessionState.canvases}, which reflects durable membership of +/// already-opened canvas INSTANCES, not the set of canvas TYPES a +/// host/extension could open; do not confuse the two. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCanvasTypesParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Maximum number of entries to return in this page. The server SHOULD respect + /// this bound but MAY return fewer entries and MAY impose its own upper cap. + /// Omit to let the server choose the page size. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + /// Omit to fetch the first page. Cursors are server-defined and MUST be treated + /// as opaque — do not parse, modify, or persist them across connections. An + /// unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +/// Available canvas types for the requested chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCanvasTypesResult { + /// Opaque cursor for the next page. Present when more entries exist beyond the + /// returned page; absent signals the end of the collection. Pass it back as + /// {@link PaginatedParams.cursor} to fetch the following page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Discovered canvas type declarations. + pub types: Vec, +} + +/// Explicitly opens (admits) a canvas, associating it with the owning chat +/// given by `identity.chat` at the moment of the call — never with whichever +/// chat later happens to have focus. +/// +/// Canvas membership requires explicit admission. A client admits a canvas +/// by calling `openCanvas`, a read-write operation. `listCanvasTypes`, +/// `subscribe`, and `resolveCanvasSource` MUST NOT admit a canvas or execute +/// or start its provider. +/// +/// A host MAY also publish membership after observing an instance already +/// opened by the owning native runtime. Before publication, the host MUST +/// correlate the observation to the actual backing chat, canonical source, +/// canvas type, and native instance, and enforce applicable +/// execution-admission policy. Uncorrelated or conflicting observations MUST +/// be rejected rather than assigned to the focused chat or a guessed source. +/// Observation does not grant execution trust: it MUST NOT convert `pending` +/// or `blocked` trust to `trusted`; trust and availability remain independent. +/// +/// Native publication follows the same singular identity-to-resource binding +/// and authoritative state rules as client-originated admission. The host +/// MUST NOT manufacture a client `openCanvas` request or invoke the provider's +/// open handler again merely to publish an already-open instance. Repeated +/// observations MUST NOT duplicate membership; this does not suppress the +/// actual effects of a genuinely new native open. Hosts MUST preserve the +/// native instance-ID namespace, including session-wide IDs across providers +/// where the owning runtime requires them, rather than hide collisions with +/// an invented provider namespace. Client `requestId` semantics are unchanged. +/// +/// Once admitted by either path, clients read and follow live state by +/// `subscribe`-ing to `canvas.resource`, and resolve the current live endpoint +/// via `resolveCanvasSource`; neither read opens, resumes, or restarts anything. +/// +/// **Logical identity is always singular.** The same {@link CanvasIdentityKey} +/// (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same +/// `canvas` resource URI and the same {@link SessionState.canvases} catalog +/// entry, no matter how many times `openCanvas` is called or a native open is +/// observed for it. The server MUST reuse that existing entry's `resource` +/// rather than mint a second one. A client-supplied `canvas` URI is honored +/// only on the call that first establishes the identity; on a later call for +/// an already-recorded identity the server MUST ignore the supplied `canvas` +/// value and return the existing resource instead. +/// +/// **Idempotency is scoped to `requestId`, not identity.** Retrying with the +/// exact same `requestId` and byte-for-byte identical params from the same +/// authenticated connection MUST return the original result without +/// repeating any side effect, within a bounded live window (the server is +/// not required to remember it forever). Reusing the same `requestId` with +/// any different parameter value MUST be rejected with `Conflict` +/// (`-32011`) — mint a new `requestId` for a new logical call. A genuinely +/// NEW `requestId` for an already-open identity MAY be effectful (e.g. +/// updating `title`/`icon`, or causing the provider to re-run its own +/// open-time initialization with new `input`) — this mirrors the pinned +/// SDK's own repeated-open behavior and does not create a second logical +/// identity. There is no exactly-once-across-crash guarantee: a lost reply +/// is indeterminate, and clients MUST NOT automatically replay `openCanvas` +/// — reconnect and read `SessionState.canvases` / `resolveCanvasSource` +/// instead to determine the actual outcome. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCanvasParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + pub canvas: Uri, + /// Logical identity to open or re-admit. + pub identity: CanvasIdentityKey, + /// Initial (or updated, on a later effectful call) display title. + pub title: String, + /// Initial (or updated) display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Bounded JSON input for this open call (e.g. seed parameters the + /// provider uses to initialize the canvas), opaque to the protocol. See + /// {@link CanvasTypeDeclaration.openInputSchema} / + /// `openInputSchemaRef` for the expected shape. The JSON-serialized value + /// MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Durable client-generated idempotency key bounding retry deduplication + /// for this call within a live window; see the idempotency rules above. + /// MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, +} + +/// Result identifying the existing or newly opened canvas. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCanvasResult { + /// The catalog entry for the opened (or already-open) canvas. + pub canvas: CanvasEntry, +} + +/// Reads a canvas's current live-resolution state and, when currently live, +/// a transient endpoint presentation. +/// +/// This is read-only with respect to membership, provider execution/lifecycle, +/// and durable canvas state. It MUST NOT admit a canvas or create, resume, +/// reopen, or restart a provider, including on unavailable or unauthorized +/// requests. Authorization failure MUST NOT expose `source`. +/// +/// For an already-live endpoint, authorized resolution MAY issue or refresh +/// transient presentation credentials while constructing the response. Two +/// resolutions of the same live incarnation MAY therefore return different +/// URLs. Credential refresh alone MUST NOT rerun provider open, change the +/// incarnation, or require a canvas state revision. If the live state changes +/// concurrently during resolution, the response MUST report that state's +/// current availability, revision, and incarnation; any returned `source` +/// MUST correspond to that reported state. +/// +/// Clients MUST NOT replace a newer attachment with a superseded resolution +/// response, even when credential renewal leaves `revision` and `incarnation` +/// unchanged. These state guards do not order same-state credential refreshes. +/// +/// If the canvas does not currently have a live endpoint, `source` is absent +/// and `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`). +/// Call `restartCanvasProvider` (an explicitly effectful operation) to +/// attempt recovery instead; calling `resolveCanvasSource` again only +/// retries reading the current state without restarting anything. +/// +/// A client-local page reload needs no provider restart or new effectful +/// command. Before reload or reattachment, the client SHOULD resolve a fresh +/// presentation unless the existing credential is known to remain valid and +/// reusable. An absent expiry hint does not imply indefinite validity or +/// reusability. Presentation URLs and credentials MUST NOT enter durable +/// membership, editor restoration data, or routine logs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveCanvasSourceParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// The canvas's current live-resolution state as of this read. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveCanvasSourceResult { + /// Current {@link CanvasEntry.availability}. + pub availability: CanvasAvailabilityStatus, + /// Current {@link CanvasIdentity.incarnation}. + pub incarnation: String, + /// Current {@link CanvasEntry.revision}. + pub revision: i64, + /// Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +/// Invokes one of a canvas's currently declared actions exactly once. +/// +/// The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's +/// current trust is not `trusted`, and with `NotFound` (`-32008`) if +/// `actionId` does not match a currently declared action. `incarnation` is +/// REQUIRED — omitting stale-generation protection on an effectful call is +/// not allowed. If it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` +/// (`-32011`) rather than route the call to a superseded endpoint. +/// +/// The result is the provider's raw reply and is never persisted into +/// `CanvasState` — large or provider-specific payloads stay off the durable +/// state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be +/// represented out of band instead of being returned inline. Any resulting +/// state changes (e.g. a subsequent availability transition) flow back +/// separately through the normal `canvas/*` action stream on the canvas's +/// own channel. +/// +/// A lost reply (e.g. a dropped connection after the provider already ran +/// the handler) is **indeterminate**: clients MUST NOT automatically replay +/// `invokeCanvasAction` on reconnect. Instead, reconnect and read the +/// canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and +/// decide from observed `revision`/`incarnation` and any provider-visible +/// side effect whether to surface the ambiguity to the user, rather than +/// assuming success or failure. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InvokeCanvasActionParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + pub action_id: String, + /// Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + /// if any. The JSON-serialized value MUST NOT exceed + /// `CANVAS_INPUT_MAX_LENGTH`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Expected {@link CanvasIdentity.incarnation}. Required — see above. The + /// server MUST reject the call with `Conflict` if the canvas's live + /// endpoint has since been superseded, rather than deliver the call to it. + pub incarnation: String, + /// Durable client-generated idempotency key bounding retry + /// deduplication for this invocation within a live window. The server is + /// not required to guarantee exactly-once execution across a crash. MUST + /// NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, +} + +/// Result of invoking a declared canvas action. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InvokeCanvasActionResult { + /// The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + pub result: AnyValue, +} + +/// Explicitly restarts the provider/chat-scoped runtime backing this canvas: +/// retires the current live endpoint and establishes a fresh one for the +/// same logical instance. +/// +/// This is the **only** operation that intentionally causes an +/// {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` +/// (read-only source resolution / client-local page reload) MUST NEVER +/// trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} +/// (transitioning through `notLoaded`/`loading`) and then +/// {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart +/// never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or +/// restore any prior in-flight effect. +/// +/// `incarnation` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasIdentity.incarnation}, so a caller cannot restart a +/// generation it never observed (e.g. after racing a concurrent restart). A +/// lost reply is indeterminate; clients MUST NOT automatically replay this +/// command — reconnect and compare the canvas's current `incarnation` +/// instead. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestartCanvasProviderParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, + /// Expected current {@link CanvasIdentity.incarnation}; required — see above. + pub incarnation: String, +} + +/// Logically closes a canvas: removes its durable membership from +/// `SessionState.canvases` and disposes matching views. +/// +/// This is distinct from a client merely hiding a local tab or view, which is +/// presentation-only and MUST NOT dispatch this command. There is no +/// advertised model tool for this operation — it is invoked only by +/// UI/RPC callers. +/// +/// `revision` is REQUIRED: the server MUST reject with `Conflict` +/// (`-32011`) if it does not match the canvas's current +/// {@link CanvasEntry.revision}, so a caller cannot close membership state it +/// never actually observed. If no matching entry exists (e.g. already +/// closed), the server MUST treat this as a successful no-op rather than an +/// error — the `revision` precondition only applies when an entry still +/// exists. A lost reply is indeterminate; clients MUST NOT automatically +/// replay this command — reconnect and check `SessionState.canvases` +/// instead. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseCanvasParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + pub request_id: String, + /// Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + pub revision: i64, +} + // ─── ChatSource Union ───────────────────────────────────────────────── /// How a new chat uses a source chat. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum ChatSource { #[serde(rename = "fork")] @@ -1692,6 +2067,25 @@ pub enum ChatSource { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ChatSource { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("fork") => serde_json::from_value::(raw) + .map(Self::Fork) + .map_err(serde::de::Error::custom), + Some("sideChat") => serde_json::from_value::(raw) + .map(Self::SideChat) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + // ─── ReconnectResult Union ──────────────────────────────────────────── /// Result of the `reconnect` command. diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 6d7ff245..34795a58 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1423,6 +1423,156 @@ pub enum AutomationRunOriginKind { Trigger, } +/// Discriminant for {@link CanvasSource} — what kind of package originates a +/// canvas type. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CanvasSourceKind { + /// An explicitly installed host extension. + Extension, + /// An explicitly installed package (not a host extension). + Package, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for CanvasSourceKind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Extension => serializer.serialize_str("extension"), + Self::Package => serializer.serialize_str("package"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for CanvasSourceKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "extension" => Self::Extension, + "package" => Self::Package, + _ => Self::Unknown(raw), + }) + } +} + +/// Discriminant for {@link CanvasTrustState} — whether the host currently +/// permits this canvas's declared actions to execute. +/// +/// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +/// a canvas may be perfectly capable of rendering while blocked from +/// executing actions, and vice versa. Trust decisions are host/runtime +/// authority, not something this protocol grants. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CanvasTrustStatus { + /// Declared actions may be invoked. + Trusted, + /// A trust decision has not yet been made (e.g. first use of a new/changed source). + Pending, + /// The host has denied execution; declared actions MUST NOT be invoked. + Blocked, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for CanvasTrustStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Trusted => serializer.serialize_str("trusted"), + Self::Pending => serializer.serialize_str("pending"), + Self::Blocked => serializer.serialize_str("blocked"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for CanvasTrustStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "trusted" => Self::Trusted, + "pending" => Self::Pending, + "blocked" => Self::Blocked, + _ => Self::Unknown(raw), + }) + } +} + +/// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +/// live resolution state, independent of its durable +/// {@link CanvasEntry | membership} in a session's catalog. +/// +/// An empty catalog membership list is not itself a close, and a canvas may +/// remain a recorded member while its live availability cycles through these +/// states any number of times (e.g. across provider restarts). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CanvasAvailabilityStatus { + /// The connected client or host does not support this canvas type (e.g. + /// the client omitted the `canvases` capability, or no local runtime can + /// render this `canvasType`). Distinct from `blocked` trust, which is a + /// policy decision rather than a capability gap. + Unsupported, + /// Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + NotLoaded, + /// Currently resolving or (re)connecting to a live endpoint. + Loading, + /// Live and reachable, but the provider has not yet produced content to render. + Empty, + /// Live, reachable, and has declared its current actions. + Ready, + /// The live endpoint failed to resolve, or resolution otherwise failed. + Failed, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for CanvasAvailabilityStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Unsupported => serializer.serialize_str("unsupported"), + Self::NotLoaded => serializer.serialize_str("notLoaded"), + Self::Loading => serializer.serialize_str("loading"), + Self::Empty => serializer.serialize_str("empty"), + Self::Ready => serializer.serialize_str("ready"), + Self::Failed => serializer.serialize_str("failed"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for CanvasAvailabilityStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "unsupported" => Self::Unsupported, + "notLoaded" => Self::NotLoaded, + "loading" => Self::Loading, + "empty" => Self::Empty, + "ready" => Self::Ready, + "failed" => Self::Failed, + _ => Self::Unknown(raw), + }) + } +} + // ─── Structs ────────────────────────────────────────────────────────── /// An optionally-sized icon that can be displayed in a user interface. @@ -2084,6 +2234,16 @@ pub struct SessionState { /// {@link /guide/changesets | Changesets} for an overview of the model. #[serde(default, skip_serializing_if = "Option::is_none")] pub changesets: Option>, + /// Catalog of canvases opened for chats in this session. Presence is + /// durable logical membership, admitted via `openCanvas` or host publication + /// of a correlated, already-open native instance under that command's + /// admission rules. Membership is never implied by discovery, subscription, + /// source resolution, a chat's existence, or a client's earlier focus. + /// Each entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the + /// exact backing chat; a canvas never migrates to a different chat. See + /// {@link CanvasEntry} for the full membership/availability/trust model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option>, /// Outstanding input the session is blocked on, aggregated across every chat /// so a client can discover and answer it from the session channel alone, /// without subscribing to individual chats. @@ -5647,6 +5807,350 @@ pub struct AutomationRunState { pub meta: Option, } +/// A canvas type provided by an installed host extension. +/// +/// `extensionId` is the identity-bearing field for comparison purposes (see +/// {@link CanvasIdentityKey}). `version` is display/informational metadata +/// only — it MUST NOT be treated as identity-bearing (two `CanvasSource` +/// values that differ only in `version` are the same source). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasExtensionSource { + /// Stable extension identifier (host-defined format, e.g. `publisher.name`). + /// MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub extension_id: String, + /// Installed extension version, when known. Metadata only — not identity-bearing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// A canvas type provided by an installed package that is not a host +/// extension (e.g. a workspace-declared runtime package). +/// +/// `sourceId` — not `packageName` — is the identity-bearing field: the same +/// declared package name MAY be installed in more than one scope (e.g. a +/// workspace-local copy and a globally-installed copy, or two different +/// registries), and each such installation is a distinct source with its own +/// `sourceId`. `packageName` and `version` are display/informational metadata +/// only and MUST NOT be treated as identity-bearing. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasPackageSource { + /// Stable, host- or package-manager-assigned unique identifier for this + /// specific installed package instance/scope (opaque format). This is the + /// identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub source_id: String, + /// Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + pub package_name: String, + /// Installed package version, when known. Metadata only — not identity-bearing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// The logical identity of a canvas, excluding the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +/// +/// Two canvases are the same logical canvas iff `chat`, `canvasType`, +/// `instanceId`, and `source`'s **identity-bearing** fields are all equal: +/// `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` +/// plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and +/// `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into +/// this comparison. Clients MUST NOT treat +/// {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — +/// it is only unique within the scope of `(chat, source, canvasType)`. +/// +/// This logical tuple does not widen the owning runtime's native instance-ID +/// namespace. A runtime may require session-wide native IDs across providers; +/// hosts MUST preserve that constraint rather than hide native collisions +/// with an invented provider namespace. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasIdentityKey { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + pub chat: Uri, + /// The extension or package that declares this canvas's type. + pub source: CanvasSource, + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub canvas_type: String, + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub instance_id: String, +} + +/// Full identity of a canvas, including the host-assigned +/// {@link CanvasIdentity.incarnation | `incarnation`}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasIdentity { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + pub chat: Uri, + /// The extension or package that declares this canvas's type. + pub source: CanvasSource, + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub canvas_type: String, + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub instance_id: String, + /// Opaque, host-generated token identifying the current generation of this + /// canvas's live endpoint. The host mints a fresh token whenever a provider + /// restart retires the previous live endpoint and establishes a new one for + /// the same logical instance (see {@link CanvasIncarnationChangedAction | + /// `canvas/incarnationChanged`}); it is not changed by a plain page reload + /// or transient presentation credential renewal for the same still-live + /// endpoint. + /// + /// `incarnation` is **opaque**: clients and hosts MUST compare it only for + /// equality, never parse it, sort it, or perform arithmetic on it (e.g. it + /// is not guaranteed to be numeric or monotonically increasing). The host + /// MUST NOT reuse a token for this logical identity once it has been + /// superseded, including across a host/process restart — if the host + /// cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + /// or timestamp-derived) that make accidental reuse practically + /// impossible, rather than a small resettable counter. + /// + /// Clients and hosts use `incarnation` to reject stale callbacks and + /// in-flight effects addressed to a superseded endpoint. + pub incarnation: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTrustedState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasPendingTrustState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CanvasBlockedTrustState { + /// Optional human-readable reason surfaced to the user. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// One action a canvas declares it can perform, invoked via +/// `invokeCanvasAction`. +/// +/// Declarations are carried only on the full {@link CanvasState}, loaded when +/// a client subscribes — never duplicated into the lightweight +/// {@link CanvasEntry} catalog entry, keeping session summaries small. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasActionDeclaration { + /// Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + pub id: String, + /// Human-readable display name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Description of what invoking the action does. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Inline JSON Schema for the expected `input`, when small enough to embed + /// (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + /// checked by {@link isCanvasSchemaWithinLimits}). Optional because some + /// declared actions take no input. Mutually exclusive with + /// `inputSchemaRef` — a declaration MUST supply at most one of the two. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Bounded out-of-band reference to a larger JSON Schema, used instead of + /// `inputSchema` when the schema would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. AHP does not mandate a specific resolution mechanism for this + /// URI (e.g. a host MAY make it `resourceRead`-able). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema_ref: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasUnsupportedAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasNotLoadedAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasLoadingAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasEmptyAvailabilityState {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReadyAvailabilityState { + /// Actions currently declared by the live provider (full replacement each time this state is produced). + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasFailedAvailabilityState { + /// Stable machine-readable and human-readable failure information. + pub error: ErrorInfo, +} + +/// Lightweight catalog entry for a canvas, carried in +/// {@link SessionState.canvases | `SessionState.canvases`}. Presence +/// represents durable **logical membership** — it is unaffected by the live +/// {@link CanvasEntry.availability | `availability`} cycling through +/// `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. +/// +/// Membership is admitted by `openCanvas` or by host publication of a +/// correlated, already-open native instance under that command's admission +/// rules, never by discovery, subscription, or source resolution. +/// +/// The full state, including declared actions, lives in {@link CanvasState}, +/// loaded when a client subscribes to {@link CanvasEntry.resource}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasEntry { + /// Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + pub resource: Uri, + /// Full identity, including current incarnation. + pub identity: CanvasIdentity, + /// Human-readable display title. + pub title: String, + /// Optional display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Current trust decision matching {@link CanvasState.trust}. + pub trust: CanvasTrustState, + /// Current availability status matching {@link CanvasState.availability}'s discriminant. + pub availability: CanvasAvailabilityStatus, + /// Monotonically increasing counter bumped on every change to this + /// canvas's state (trust, availability, or incarnation). Clients MAY use it + /// to detect and reject stale reads without a full deep comparison. + /// Transient presentation credential renewal alone does not require a + /// revision change. + pub revision: i64, + /// Opaque host-defined summary metadata. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Full state for a single canvas, loaded when a client subscribes to the +/// canvas's URI. +/// +/// `CanvasState` **denormalizes** every {@link CanvasEntry} field directly +/// onto itself, replacing `availability`'s lightweight status with the full +/// {@link CanvasAvailabilityState} (including declared actions or failure +/// detail). Producers MUST keep the two representations consistent: any +/// change to the inlined fields SHOULD also be announced on the owning +/// session via {@link SessionCanvasSetAction | `session/canvasSet`}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasState { + /// URI of this canvas channel. + pub resource: Uri, + /// Full identity, including current incarnation. + pub identity: CanvasIdentity, + /// Human-readable display title. + pub title: String, + /// Optional display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Current trust decision. + pub trust: CanvasTrustState, + /// Current live resolution state. + pub availability: CanvasAvailabilityState, + /// Matches {@link CanvasEntry.revision}. + pub revision: i64, + /// Opaque host-defined metadata. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// A canvas type an installed extension or package currently makes available +/// to open for a chat, as returned by `listCanvasTypes`. +/// +/// `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is +/// unrelated to {@link CanvasEntry}, which represents durable membership of +/// an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the +/// catalogue (via `listCanvasTypes`) MUST NOT execute or start a provider, +/// or open, materialize, or admit a canvas. Membership requires `openCanvas` +/// or host publication of a correlated, already-open native instance under +/// that command's admission rules. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasTypeDeclaration { + /// The extension or package that declares this canvas type. + pub source: CanvasSource, + /// Provider-declared canvas type (host/provider-defined format), passed as + /// {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + pub canvas_type: String, + /// Human-readable display name for a canvas-type picker. + pub title: String, + /// Description of what this canvas type does. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional display icon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Inline JSON Schema describing the `openCanvas` `input` this type + /// expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + /// / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + /// `openInputSchemaRef`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_input_schema: Option, + /// Bounded out-of-band reference to a larger open-input JSON Schema, used + /// instead of `openInputSchema` when it would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_input_schema_ref: Option, + /// Advisory, statically-known preview of actions this canvas type + /// typically declares once opened (bounded to + /// {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + /// the actual invocable actions for an opened instance are always + /// {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + /// depend on live provider configuration) and MUST be used instead of this + /// preview once the canvas is open. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub declared_actions: Option>, +} + +/// Transient, renderer-neutral presentation of a canvas's current live +/// endpoint, returned by `resolveCanvasSource`. +/// +/// This is a plain URL, not any renderer- or process-model-specific handle +/// (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview +/// panel reference) — how a client actually presents it (a VS Code Webview, +/// the Integrated Browser, or otherwise) is entirely a client/host +/// implementation detail outside this protocol. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasSourcePresentation { + /// Ephemeral URL to the canvas's current live endpoint. Transient: MUST + /// NOT be persisted (including durable canvas/session state or editor + /// restoration data), written to routine logs, or treated as a stable + /// identity. A host MAY embed + /// short-lived, single-use credentials in it; such credentials are never + /// durable authority. Renewed credentials MAY produce a different URL for + /// the same incarnation and revision. Reuse is safe only while the + /// credential is known to remain valid and reusable. + pub url: String, + /// Advisory expiry hint for `url` (and any embedded credential), when + /// known. Omission does not imply indefinite validity or reusability, and + /// an unexpired credential may still be single-use. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + // ─── Customization Enablement Union ─────────────────────────────────────── /// A single explicit customization enablement decision. @@ -5717,7 +6221,7 @@ pub enum ChatOrigin { } /// A single part of a response stream (text, tool call, reasoning, content reference). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum ResponsePart { #[serde(rename = "markdown")] @@ -5740,8 +6244,44 @@ pub enum ResponsePart { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ResponsePart { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("markdown") => serde_json::from_value::(raw) + .map(Self::Markdown) + .map_err(serde::de::Error::custom), + Some("contentRef") => serde_json::from_value::(raw) + .map(Self::ContentRef) + .map_err(serde::de::Error::custom), + Some("toolCall") => serde_json::from_value::(raw) + .map(|value| Self::ToolCall(Box::new(value))) + .map_err(serde::de::Error::custom), + Some("reasoning") => serde_json::from_value::(raw) + .map(Self::Reasoning) + .map_err(serde::de::Error::custom), + Some("systemNotification") => { + serde_json::from_value::(raw) + .map(Self::SystemNotification) + .map_err(serde::de::Error::custom) + } + Some("inputRequest") => serde_json::from_value::(raw) + .map(Self::InputRequest) + .map_err(serde::de::Error::custom), + Some("error") => serde_json::from_value::(raw) + .map(Self::Error) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Full tool call lifecycle state. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "status")] pub enum ToolCallState { #[serde(rename = "streaming")] @@ -5764,8 +6304,46 @@ pub enum ToolCallState { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ToolCallState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("status").and_then(serde_json::Value::as_str); + match discriminator { + Some("streaming") => serde_json::from_value::(raw) + .map(Self::Streaming) + .map_err(serde::de::Error::custom), + Some("pending-confirmation") => { + serde_json::from_value::(raw) + .map(Self::PendingConfirmation) + .map_err(serde::de::Error::custom) + } + Some("running") => serde_json::from_value::(raw) + .map(Self::Running) + .map_err(serde::de::Error::custom), + Some("auth-required") => serde_json::from_value::(raw) + .map(|value| Self::AuthRequired(Box::new(value))) + .map_err(serde::de::Error::custom), + Some("pending-result-confirmation") => { + serde_json::from_value::(raw) + .map(Self::PendingResultConfirmation) + .map_err(serde::de::Error::custom) + } + Some("completed") => serde_json::from_value::(raw) + .map(Self::Completed) + .map_err(serde::de::Error::custom), + Some("cancelled") => serde_json::from_value::(raw) + .map(Self::Cancelled) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// A tool call blocked on parameter- or result-confirmation. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "status")] pub enum ToolCallConfirmationState { #[serde(rename = "pending-confirmation")] @@ -5778,6 +6356,29 @@ pub enum ToolCallConfirmationState { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ToolCallConfirmationState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("status").and_then(serde_json::Value::as_str); + match discriminator { + Some("pending-confirmation") => { + serde_json::from_value::(raw) + .map(Self::PendingConfirmation) + .map_err(serde::de::Error::custom) + } + Some("pending-result-confirmation") => { + serde_json::from_value::(raw) + .map(Self::PendingResultConfirmation) + .map_err(serde::de::Error::custom) + } + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Who currently holds a terminal. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] @@ -5789,7 +6390,7 @@ pub enum TerminalClaim { } /// A content part within terminal output. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type")] pub enum TerminalContentPart { #[serde(rename = "unclassified")] @@ -5802,8 +6403,27 @@ pub enum TerminalContentPart { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for TerminalContentPart { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("type").and_then(serde_json::Value::as_str); + match discriminator { + Some("unclassified") => serde_json::from_value::(raw) + .map(Self::Unclassified) + .map_err(serde::de::Error::custom), + Some("command") => serde_json::from_value::(raw) + .map(Self::Command) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// One question within a chat input request. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum ChatInputQuestion { #[serde(rename = "text")] @@ -5824,8 +6444,39 @@ pub enum ChatInputQuestion { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ChatInputQuestion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("text") => serde_json::from_value::(raw) + .map(Self::Text) + .map_err(serde::de::Error::custom), + Some("number") => serde_json::from_value::(raw) + .map(Self::Number) + .map_err(serde::de::Error::custom), + Some("integer") => serde_json::from_value::(raw) + .map(Self::Integer) + .map_err(serde::de::Error::custom), + Some("boolean") => serde_json::from_value::(raw) + .map(Self::Boolean) + .map_err(serde::de::Error::custom), + Some("single-select") => serde_json::from_value::(raw) + .map(Self::SingleSelect) + .map_err(serde::de::Error::custom), + Some("multi-select") => serde_json::from_value::(raw) + .map(Self::MultiSelect) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Value captured for one answer. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum ChatInputAnswerValue { #[serde(rename = "text")] @@ -5844,6 +6495,36 @@ pub enum ChatInputAnswerValue { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ChatInputAnswerValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("text") => serde_json::from_value::(raw) + .map(Self::Text) + .map_err(serde::de::Error::custom), + Some("number") => serde_json::from_value::(raw) + .map(Self::Number) + .map_err(serde::de::Error::custom), + Some("boolean") => serde_json::from_value::(raw) + .map(Self::Boolean) + .map_err(serde::de::Error::custom), + Some("selected") => serde_json::from_value::(raw) + .map(Self::Selected) + .map_err(serde::de::Error::custom), + Some("selected-many") => { + serde_json::from_value::(raw) + .map(Self::SelectedMany) + .map_err(serde::de::Error::custom) + } + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Draft, submitted, or skipped answer for one question. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "state")] @@ -5857,7 +6538,7 @@ pub enum ChatInputAnswer { } /// Content block in a tool result. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type")] pub enum ToolResultContent { #[serde(rename = "text")] @@ -5878,8 +6559,41 @@ pub enum ToolResultContent { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ToolResultContent { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("type").and_then(serde_json::Value::as_str); + match discriminator { + Some("text") => serde_json::from_value::(raw) + .map(Self::Text) + .map_err(serde::de::Error::custom), + Some("embeddedResource") => { + serde_json::from_value::(raw) + .map(Self::EmbeddedResource) + .map_err(serde::de::Error::custom) + } + Some("resource") => serde_json::from_value::(raw) + .map(Self::Resource) + .map_err(serde::de::Error::custom), + Some("fileEdit") => serde_json::from_value::(raw) + .map(Self::FileEdit) + .map_err(serde::de::Error::custom), + Some("terminal") => serde_json::from_value::(raw) + .map(Self::Terminal) + .map_err(serde::de::Error::custom), + Some("subagent") => serde_json::from_value::(raw) + .map(Self::Subagent) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// An attachment associated with a `Message`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type")] pub enum MessageAttachment { #[serde(rename = "simple")] @@ -5898,8 +6612,38 @@ pub enum MessageAttachment { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for MessageAttachment { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("type").and_then(serde_json::Value::as_str); + match discriminator { + Some("simple") => serde_json::from_value::(raw) + .map(Self::Simple) + .map_err(serde::de::Error::custom), + Some("embeddedResource") => { + serde_json::from_value::(raw) + .map(Self::EmbeddedResource) + .map_err(serde::de::Error::custom) + } + Some("resource") => serde_json::from_value::(raw) + .map(Self::Resource) + .map_err(serde::de::Error::custom), + Some("annotations") => serde_json::from_value::(raw) + .map(Self::Annotations) + .map_err(serde::de::Error::custom), + Some("chat") => serde_json::from_value::(raw) + .map(Self::Chat) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// A top-level customization (plugin, directory, or bare MCP server). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type")] pub enum Customization { #[serde(rename = "plugin")] @@ -5914,8 +6658,30 @@ pub enum Customization { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for Customization { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("type").and_then(serde_json::Value::as_str); + match discriminator { + Some("plugin") => serde_json::from_value::(raw) + .map(Self::Plugin) + .map_err(serde::de::Error::custom), + Some("directory") => serde_json::from_value::(raw) + .map(Self::Directory) + .map_err(serde::de::Error::custom), + Some("mcpServer") => serde_json::from_value::(raw) + .map(|value| Self::McpServer(Box::new(value))) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// A child customization living inside a plugin or directory. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type")] pub enum ChildCustomization { #[serde(rename = "agent")] @@ -5936,6 +6702,37 @@ pub enum ChildCustomization { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ChildCustomization { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("type").and_then(serde_json::Value::as_str); + match discriminator { + Some("agent") => serde_json::from_value::(raw) + .map(Self::Agent) + .map_err(serde::de::Error::custom), + Some("skill") => serde_json::from_value::(raw) + .map(Self::Skill) + .map_err(serde::de::Error::custom), + Some("prompt") => serde_json::from_value::(raw) + .map(Self::Prompt) + .map_err(serde::de::Error::custom), + Some("rule") => serde_json::from_value::(raw) + .map(Self::Rule) + .map_err(serde::de::Error::custom), + Some("hook") => serde_json::from_value::(raw) + .map(Self::Hook) + .map_err(serde::de::Error::custom), + Some("mcpServer") => serde_json::from_value::(raw) + .map(|value| Self::McpServer(Box::new(value))) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Host-reported load state for a container customization. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] @@ -5951,7 +6748,7 @@ pub enum CustomizationLoadState { } /// Discriminated lifecycle status of an MCP server customization. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum McpServerState { #[serde(rename = "starting")] @@ -5970,8 +6767,36 @@ pub enum McpServerState { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for McpServerState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("starting") => serde_json::from_value::(raw) + .map(Self::Starting) + .map_err(serde::de::Error::custom), + Some("ready") => serde_json::from_value::(raw) + .map(Self::Ready) + .map_err(serde::de::Error::custom), + Some("authRequired") => serde_json::from_value::(raw) + .map(|value| Self::AuthRequired(Box::new(value))) + .map_err(serde::de::Error::custom), + Some("error") => serde_json::from_value::(raw) + .map(Self::Error) + .map_err(serde::de::Error::custom), + Some("stopped") => serde_json::from_value::(raw) + .map(Self::Stopped) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Reference to the contributor of the tool being called. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum ToolCallContributor { #[serde(rename = "client")] @@ -5984,8 +6809,27 @@ pub enum ToolCallContributor { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ToolCallContributor { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("client") => serde_json::from_value::(raw) + .map(Self::Client) + .map_err(serde::de::Error::custom), + Some("mcp") => serde_json::from_value::(raw) + .map(Self::Mcp) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Asynchronous model-judge confirmation rationale. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "status")] pub enum ToolCallRiskAssessment { #[serde(rename = "loading")] @@ -5998,6 +6842,25 @@ pub enum ToolCallRiskAssessment { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for ToolCallRiskAssessment { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("status").and_then(serde_json::Value::as_str); + match discriminator { + Some("loading") => serde_json::from_value::(raw) + .map(Self::Loading) + .map_err(serde::de::Error::custom), + Some("complete") => serde_json::from_value::(raw) + .map(Self::Complete) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Current lifecycle of a terminal process. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "status")] @@ -6009,7 +6872,7 @@ pub enum TerminalLifecycleState { } /// One outstanding piece of input a session is blocked on, aggregated across all chats. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum SessionInputRequest { #[serde(rename = "chatInput")] @@ -6026,8 +6889,39 @@ pub enum SessionInputRequest { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for SessionInputRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("chatInput") => serde_json::from_value::(raw) + .map(Self::ChatInput) + .map_err(serde::de::Error::custom), + Some("toolConfirmation") => { + serde_json::from_value::(raw) + .map(Self::ToolConfirmation) + .map_err(serde::de::Error::custom) + } + Some("toolClientExecution") => { + serde_json::from_value::(raw) + .map(Self::ToolClientExecution) + .map_err(serde::de::Error::custom) + } + Some("toolAuthentication") => { + serde_json::from_value::(raw) + .map(|value| Self::ToolAuthentication(Box::new(value))) + .map_err(serde::de::Error::custom) + } + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Durable origin of a session. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum SessionOrigin { #[serde(rename = "automation")] @@ -6038,6 +6932,22 @@ pub enum SessionOrigin { Unknown(serde_json::Value), } +impl<'de> Deserialize<'de> for SessionOrigin { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("automation") => serde_json::from_value::(raw) + .map(Self::Automation) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// Automatic trigger for an automation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] @@ -6074,6 +6984,132 @@ pub enum AutomationRunLifecycle { Cancelled(AutomationCancelledRunLifecycle), } +/// Identifies the explicitly installed extension or package that declares a canvas type. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind")] +pub enum CanvasSource { + #[serde(rename = "extension")] + Extension(CanvasExtensionSource), + #[serde(rename = "package")] + Package(CanvasPackageSource), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + +impl<'de> Deserialize<'de> for CanvasSource { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("kind").and_then(serde_json::Value::as_str); + match discriminator { + Some("extension") => serde_json::from_value::(raw) + .map(Self::Extension) + .map_err(serde::de::Error::custom), + Some("package") => serde_json::from_value::(raw) + .map(Self::Package) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + +/// Current trust decision governing whether a canvas's declared actions may execute. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "status")] +pub enum CanvasTrustState { + #[serde(rename = "trusted")] + Trusted(CanvasTrustedState), + #[serde(rename = "pending")] + Pending(CanvasPendingTrustState), + #[serde(rename = "blocked")] + Blocked(CanvasBlockedTrustState), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + +impl<'de> Deserialize<'de> for CanvasTrustState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("status").and_then(serde_json::Value::as_str); + match discriminator { + Some("trusted") => serde_json::from_value::(raw) + .map(Self::Trusted) + .map_err(serde::de::Error::custom), + Some("pending") => serde_json::from_value::(raw) + .map(Self::Pending) + .map_err(serde::de::Error::custom), + Some("blocked") => serde_json::from_value::(raw) + .map(Self::Blocked) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + +/// Current live resolution state of a canvas. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "status")] +pub enum CanvasAvailabilityState { + #[serde(rename = "unsupported")] + Unsupported(CanvasUnsupportedAvailabilityState), + #[serde(rename = "notLoaded")] + NotLoaded(CanvasNotLoadedAvailabilityState), + #[serde(rename = "loading")] + Loading(CanvasLoadingAvailabilityState), + #[serde(rename = "empty")] + Empty(CanvasEmptyAvailabilityState), + #[serde(rename = "ready")] + Ready(CanvasReadyAvailabilityState), + #[serde(rename = "failed")] + Failed(CanvasFailedAvailabilityState), + /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. + /// Reducers treat this as a no-op. + #[serde(untagged)] + Unknown(serde_json::Value), +} + +impl<'de> Deserialize<'de> for CanvasAvailabilityState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = serde_json::Value::deserialize(deserializer)?; + let discriminator = raw.get("status").and_then(serde_json::Value::as_str); + match discriminator { + Some("unsupported") => { + serde_json::from_value::(raw) + .map(Self::Unsupported) + .map_err(serde::de::Error::custom) + } + Some("notLoaded") => serde_json::from_value::(raw) + .map(Self::NotLoaded) + .map_err(serde::de::Error::custom), + Some("loading") => serde_json::from_value::(raw) + .map(Self::Loading) + .map_err(serde::de::Error::custom), + Some("empty") => serde_json::from_value::(raw) + .map(Self::Empty) + .map_err(serde::de::Error::custom), + Some("ready") => serde_json::from_value::(raw) + .map(Self::Ready) + .map_err(serde::de::Error::custom), + Some("failed") => serde_json::from_value::(raw) + .map(Self::Failed) + .map_err(serde::de::Error::custom), + _ => Ok(Self::Unknown(raw)), + } + } +} + /// The state payload of a snapshot. /// /// Deserialized by trying session first (has required `lifecycle`), then diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 15cc81d3..042f4492 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.9.0"; +pub const PROTOCOL_VERSION: &str = "0.10.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,5 +13,6 @@ pub const PROTOCOL_VERSION: &str = "0.9.0"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = - &["0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1", +]; diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index 1b81a9ca..d1ee528a 100644 --- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -36,6 +36,16 @@ use ahp_types::{ version::{PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}, }; use serde_json::{Number, Value}; + +#[test] +fn canvas_icon_changed_requires_icon() { + let result = + serde_json::from_str::(r#"{"type":"canvas/iconChanged","revision":3}"#); + assert!( + result.is_err(), + "canvas/iconChanged without icon decoded successfully" + ); +} use std::{collections::BTreeMap, fs, path::PathBuf}; // ─── Fixture directory ─────────────────────────────────────────────────────── diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 95b02ff4..b50e30a2 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -4,7 +4,7 @@ //! [`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_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,14 +57,14 @@ use ahp_types::actions::{ ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, ChangesetOperationStatus, - ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, - ConfirmationOption, Customization, CustomizationEnablement, ErrorResponsePart, - InputRequestResponsePart, McpServerStartingState, McpServerState, McpServerStoppedState, - PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, - SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, - TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, TerminalState, - TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, + ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, CanvasState, + ChangesetOperationStatus, ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, + ChildCustomization, ConfirmationOption, Customization, CustomizationEnablement, + ErrorResponsePart, InputRequestResponsePart, McpServerStartingState, McpServerState, + McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, + RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + TerminalCommandPart, TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, + TerminalState, TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, ToolCallCancelledState, ToolCallCompletedState, ToolCallConfirmationReason, ToolCallContributor, ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStatus, @@ -715,6 +715,34 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - } ReduceOutcome::Applied } + StateAction::SessionCanvasSet(a) => { + let canvases = state.canvases.get_or_insert_with(Vec::new); + if let Some(idx) = canvases + .iter() + .position(|canvas| canvas.resource == a.canvas.resource) + { + if a.canvas.revision <= canvases[idx].revision { + return ReduceOutcome::NoOp; + } + canvases[idx] = a.canvas.clone(); + } else { + canvases.push(a.canvas.clone()); + } + ReduceOutcome::Applied + } + StateAction::SessionCanvasRemoved(a) => { + let Some(canvases) = state.canvases.as_mut() else { + return ReduceOutcome::NoOp; + }; + let Some(idx) = canvases + .iter() + .position(|canvas| canvas.resource == a.resource) + else { + return ReduceOutcome::NoOp; + }; + canvases.remove(idx); + ReduceOutcome::Applied + } StateAction::SessionChatRemoved(a) => { let Some(idx) = state.chats.iter().position(|chat| chat.resource == a.chat) else { return ReduceOutcome::NoOp; @@ -2068,6 +2096,49 @@ pub fn apply_action_to_resource_watch( } } +/// Apply a [`StateAction`] to a [`CanvasState`] in place. +pub fn apply_action_to_canvas(state: &mut CanvasState, action: &StateAction) -> ReduceOutcome { + match action { + StateAction::CanvasAvailabilityChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.availability = a.availability.clone(); + state.revision = a.revision; + } + StateAction::CanvasTrustChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.trust = a.trust.clone(); + state.revision = a.revision; + } + StateAction::CanvasIncarnationChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.identity.incarnation = a.incarnation.clone(); + state.revision = a.revision; + } + StateAction::CanvasTitleChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.title = a.title.clone(); + state.revision = a.revision; + } + StateAction::CanvasIconChanged(a) => { + if a.revision <= state.revision { + return ReduceOutcome::NoOp; + } + state.icon = a.icon.clone(); + state.revision = a.revision; + } + _ => return ReduceOutcome::OutOfScope, + } + ReduceOutcome::Applied +} + /// Apply a [`StateAction`] to an [`AutomationState`] in place. pub fn apply_action_to_automation( state: &mut AutomationState, @@ -2184,6 +2255,7 @@ mod tests { config: None, customizations: None, changesets: None, + canvases: None, input_needed: None, meta: None, } @@ -2647,6 +2719,14 @@ mod tests { &file_name, description, ), + "canvas" => run_fixture::( + initial, + expected, + &parsed_actions, + apply_action_to_canvas, + &file_name, + description, + ), "automation" => run_fixture::( initial, expected, diff --git a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs index 4dd42c91..1815893f 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -65,6 +65,7 @@ fn session_state(title: &str, _resource: &str) -> SessionState { config: None, customizations: None, changesets: None, + canvases: None, input_needed: None, meta: None, } diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index b93f3893..a756c2ac 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,6 +2,7 @@ "client": "rust", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 3abc0a93..58fe5873 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -102,6 +102,13 @@ public enum ActionType: Codable, Sendable, Equatable { case automationRunSessionRemoved case automationRunPrimarySessionChanged case automationRunCancelRequested + case sessionCanvasSet + case sessionCanvasRemoved + case canvasAvailabilityChanged + case canvasTrustChanged + case canvasIncarnationChanged + case canvasTitleChanged + case canvasIconChanged /// Unknown raw value from a newer protocol version, preserved verbatim. case unknown(String) @@ -205,6 +212,13 @@ public enum ActionType: Codable, Sendable, Equatable { case "automationRun/sessionRemoved": self = .automationRunSessionRemoved case "automationRun/primarySessionChanged": self = .automationRunPrimarySessionChanged case "automationRun/cancelRequested": self = .automationRunCancelRequested + case "session/canvasSet": self = .sessionCanvasSet + case "session/canvasRemoved": self = .sessionCanvasRemoved + case "canvas/availabilityChanged": self = .canvasAvailabilityChanged + case "canvas/trustChanged": self = .canvasTrustChanged + case "canvas/incarnationChanged": self = .canvasIncarnationChanged + case "canvas/titleChanged": self = .canvasTitleChanged + case "canvas/iconChanged": self = .canvasIconChanged default: self = .unknown(raw) } } @@ -308,6 +322,13 @@ public enum ActionType: Codable, Sendable, Equatable { case .automationRunSessionRemoved: try container.encode("automationRun/sessionRemoved") case .automationRunPrimarySessionChanged: try container.encode("automationRun/primarySessionChanged") case .automationRunCancelRequested: try container.encode("automationRun/cancelRequested") + case .sessionCanvasSet: try container.encode("session/canvasSet") + case .sessionCanvasRemoved: try container.encode("session/canvasRemoved") + case .canvasAvailabilityChanged: try container.encode("canvas/availabilityChanged") + case .canvasTrustChanged: try container.encode("canvas/trustChanged") + case .canvasIncarnationChanged: try container.encode("canvas/incarnationChanged") + case .canvasTitleChanged: try container.encode("canvas/titleChanged") + case .canvasIconChanged: try container.encode("canvas/iconChanged") case .unknown(let raw): try container.encode(raw) } } @@ -2344,6 +2365,148 @@ public struct AutomationRunCancelRequestedAction: Codable, Sendable { } } +public struct SessionCanvasSetAction: Codable, Sendable { + public var type: ActionType + /// The canvas entry to add or update, matched by `resource`. + public var canvas: CanvasEntry + + public init( + type: ActionType, + canvas: CanvasEntry + ) { + self.type = type + self.canvas = canvas + } +} + +public struct SessionCanvasRemovedAction: Codable, Sendable { + public var type: ActionType + /// Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. + public var resource: String + + public init( + type: ActionType, + resource: String + ) { + self.type = type + self.resource = resource + } +} + +public struct CanvasAvailabilityChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasState.availability}. + public var availability: CanvasAvailabilityState + /// The {@link CanvasState.revision} this action results in. The reducer + /// MUST reject (no-op) this action if `revision` is not strictly greater + /// than the canvas's current `revision` — this is how stale/out-of-order + /// deliveries are consistently rejected across every canvas action, not + /// just this one. + public var revision: Int + + public init( + type: ActionType, + availability: CanvasAvailabilityState, + revision: Int + ) { + self.type = type + self.availability = availability + self.revision = revision + } +} + +public struct CanvasTrustChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasState.trust}. + public var trust: CanvasTrustState + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public var revision: Int + + public init( + type: ActionType, + trust: CanvasTrustState, + revision: Int + ) { + self.type = type + self.trust = trust + self.revision = revision + } +} + +public struct CanvasIncarnationChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. + public var incarnation: String + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public var revision: Int + + public init( + type: ActionType, + incarnation: String, + revision: Int + ) { + self.type = type + self.incarnation = incarnation + self.revision = revision + } +} + +public struct CanvasTitleChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasState.title}. + public var title: String + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public var revision: Int + + public init( + type: ActionType, + title: String, + revision: Int + ) { + self.type = type + self.title = title + self.revision = revision + } +} + +public struct CanvasIconChangedAction: Codable, Sendable { + public var type: ActionType + /// New {@link CanvasState.icon}; `null` removes the current icon. + public var icon: Icon? + /// The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. + public var revision: Int + + enum CodingKeys: String, CodingKey { + case type + case icon + case revision + } + + public init( + type: ActionType, + icon: Icon?, + revision: Int + ) { + self.type = type + self.icon = icon + self.revision = revision + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(ActionType.self, forKey: .type) + self.icon = try container.decode(Icon?.self, forKey: .icon) + self.revision = try container.decode(Int.self, forKey: .revision) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(icon, forKey: .icon) + try container.encode(revision, forKey: .revision) + } +} + // MARK: - Partial Summary Types public struct PartialChatSummary: Codable, Sendable { @@ -2490,6 +2653,13 @@ public enum StateAction: Codable, Sendable { case automationRunSessionRemoved(AutomationRunSessionRemovedAction) case automationRunPrimarySessionChanged(AutomationRunPrimarySessionChangedAction) case automationRunCancelRequested(AutomationRunCancelRequestedAction) + case sessionCanvasSet(SessionCanvasSetAction) + case sessionCanvasRemoved(SessionCanvasRemovedAction) + case canvasAvailabilityChanged(CanvasAvailabilityChangedAction) + case canvasTrustChanged(CanvasTrustChangedAction) + case canvasIncarnationChanged(CanvasIncarnationChangedAction) + case canvasTitleChanged(CanvasTitleChangedAction) + case canvasIconChanged(CanvasIconChangedAction) /// Unknown or future action type; reducers treat this as a no-op. /// The raw payload (including its `type` discriminant) is preserved /// as an `AnyCodable` so a decode→encode round-trip re-emits it @@ -2694,6 +2864,20 @@ public enum StateAction: Codable, Sendable { self = .automationRunPrimarySessionChanged(try AutomationRunPrimarySessionChangedAction(from: decoder)) case "automationRun/cancelRequested": self = .automationRunCancelRequested(try AutomationRunCancelRequestedAction(from: decoder)) + case "session/canvasSet": + self = .sessionCanvasSet(try SessionCanvasSetAction(from: decoder)) + case "session/canvasRemoved": + self = .sessionCanvasRemoved(try SessionCanvasRemovedAction(from: decoder)) + case "canvas/availabilityChanged": + self = .canvasAvailabilityChanged(try CanvasAvailabilityChangedAction(from: decoder)) + case "canvas/trustChanged": + self = .canvasTrustChanged(try CanvasTrustChangedAction(from: decoder)) + case "canvas/incarnationChanged": + self = .canvasIncarnationChanged(try CanvasIncarnationChangedAction(from: decoder)) + case "canvas/titleChanged": + self = .canvasTitleChanged(try CanvasTitleChangedAction(from: decoder)) + case "canvas/iconChanged": + self = .canvasIconChanged(try CanvasIconChangedAction(from: decoder)) default: self = .unknown(try AnyCodable(from: decoder)) } @@ -2797,6 +2981,13 @@ public enum StateAction: Codable, Sendable { case .automationRunSessionRemoved(let v): try v.encode(to: encoder) case .automationRunPrimarySessionChanged(let v): try v.encode(to: encoder) case .automationRunCancelRequested(let v): try v.encode(to: encoder) + case .sessionCanvasSet(let v): try v.encode(to: encoder) + case .sessionCanvasRemoved(let v): try v.encode(to: encoder) + case .canvasAvailabilityChanged(let v): try v.encode(to: encoder) + case .canvasTrustChanged(let v): try v.encode(to: encoder) + case .canvasIncarnationChanged(let v): try v.encode(to: encoder) + case .canvasTitleChanged(let v): try v.encode(to: encoder) + case .canvasIconChanged(let v): try v.encode(to: encoder) case .unknown(let value): try value.encode(to: encoder) } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 9d9ca3e2..541284c8 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -341,6 +341,22 @@ public struct InitializeResult: Codable, Sendable { /// `ahp-automations://` for {@link AutomationState}; absence means the /// host does not expose an automation catalogue or automation commands. public var automations: AutomationCapabilities? + /// Host/runtime-owned local-canvas support. Presence means the SERVER + /// currently has a working runtime able to serve `openCanvas` / + /// `invokeCanvasAction` for at least one qualifying (explicitly installed + /// and trust-eligible) extension/package source; absence means the host + /// has no available canvas runtime, and clients MUST treat every canvas as + /// {@link CanvasAvailabilityStatus.Unsupported} regardless of what + /// {@link ClientCapabilities.canvases} declared. + /// + /// **Protocol version support alone is not a runtime capability**: a host + /// speaking protocol `>= 0.10.0` without this field present MUST NOT be + /// assumed to have a usable canvas runtime. This field — not the + /// negotiated `protocolVersion` — is the authoritative signal, and is + /// independent of any individual canvas's live availability + /// ({@link CanvasAvailabilityState}) or trust decision + /// ({@link CanvasTrustState}). + public var canvases: CanvasCapabilities? enum CodingKeys: String, CodingKey { case protocolVersion @@ -353,6 +369,7 @@ public struct InitializeResult: Codable, Sendable { case terminalCommandPrefix case telemetry case automations + case canvases } public init( @@ -365,7 +382,8 @@ public struct InitializeResult: Codable, Sendable { completionTriggerCharacters: [String]? = nil, terminalCommandPrefix: String? = nil, telemetry: TelemetryCapabilities? = nil, - automations: AutomationCapabilities? = nil + automations: AutomationCapabilities? = nil, + canvases: CanvasCapabilities? = nil ) { self.protocolVersion = protocolVersion self.serverSeq = serverSeq @@ -377,6 +395,7 @@ public struct InitializeResult: Codable, Sendable { self.terminalCommandPrefix = terminalCommandPrefix self.telemetry = telemetry self.automations = automations + self.canvases = canvases } } @@ -393,11 +412,30 @@ public struct ClientCapabilities: Codable, Sendable { /// capability is declared. Clients that omit it MUST treat /// App-bearing tool calls as ordinary MCP tool calls. public var mcpApps: [String: AnyCodable]? + /// Client can render local canvases: `listCanvasTypes`, `openCanvas`, + /// subscribe to the resulting `ahp-canvas:` channel, and drive + /// `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + /// `closeCanvas`. + /// + /// Hosts SHOULD NOT offer canvas admission to a client that omits this + /// capability; such a client MUST be treated as if every canvas were + /// {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + /// anything about server/runtime execution trust — see + /// {@link CanvasTrustStatus}, which is a separate, host-owned decision. + /// + /// This declares only the CLIENT's rendering capability. Protocol version + /// support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + /// actually has a working canvas runtime — see + /// {@link InitializeResult.canvases}, the server-side counterpart, which a + /// client MUST also check before treating canvases as usable. + public var canvases: [String: AnyCodable]? public init( - mcpApps: [String: AnyCodable]? = nil + mcpApps: [String: AnyCodable]? = nil, + canvases: [String: AnyCodable]? = nil ) { self.mcpApps = mcpApps + self.canvases = canvases } } @@ -427,6 +465,14 @@ public struct AutomationCapabilities: Codable, Sendable { } } +public struct CanvasCapabilities: Codable, Sendable { + + public init( + + ) { + } +} + public struct AutomationCreateCapability: Codable, Sendable { public init( @@ -2090,6 +2136,296 @@ public struct FetchAutomationRunsResult: Codable, Sendable { } } +public struct ListCanvasTypesParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Maximum number of entries to return in this page. The server SHOULD respect + /// this bound but MAY return fewer entries and MAY impose its own upper cap. + /// Omit to let the server choose the page size. + public var limit: Int? + /// Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + /// Omit to fetch the first page. Cursors are server-defined and MUST be treated + /// as opaque — do not parse, modify, or persist them across connections. An + /// unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + public var cursor: String? + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case limit + case cursor + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + limit: Int? = nil, + cursor: String? = nil + ) { + self.channel = channel + self.meta = meta + self.limit = limit + self.cursor = cursor + } +} + +public struct ListCanvasTypesResult: Codable, Sendable { + /// Opaque cursor for the next page. Present when more entries exist beyond the + /// returned page; absent signals the end of the collection. Pass it back as + /// {@link PaginatedParams.cursor} to fetch the following page. + public var nextCursor: String? + /// Discovered canvas type declarations. + public var types: [CanvasTypeDeclaration] + + public init( + nextCursor: String? = nil, + types: [CanvasTypeDeclaration] + ) { + self.nextCursor = nextCursor + self.types = types + } +} + +public struct OpenCanvasParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. + public var canvas: String + /// Logical identity to open or re-admit. + public var identity: CanvasIdentityKey + /// Initial (or updated, on a later effectful call) display title. + public var title: String + /// Initial (or updated) display icon. + public var icon: Icon? + /// Bounded JSON input for this open call (e.g. seed parameters the + /// provider uses to initialize the canvas), opaque to the protocol. See + /// {@link CanvasTypeDeclaration.openInputSchema} / + /// `openInputSchemaRef` for the expected shape. The JSON-serialized value + /// MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + public var input: AnyCodable? + /// Durable client-generated idempotency key bounding retry deduplication + /// for this call within a live window; see the idempotency rules above. + /// MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case canvas + case identity + case title + case icon + case input + case requestId + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + canvas: String, + identity: CanvasIdentityKey, + title: String, + icon: Icon? = nil, + input: AnyCodable? = nil, + requestId: String + ) { + self.channel = channel + self.meta = meta + self.canvas = canvas + self.identity = identity + self.title = title + self.icon = icon + self.input = input + self.requestId = requestId + } +} + +public struct OpenCanvasResult: Codable, Sendable { + /// The catalog entry for the opened (or already-open) canvas. + public var canvas: CanvasEntry + + public init( + canvas: CanvasEntry + ) { + self.canvas = canvas + } +} + +public struct ResolveCanvasSourceParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil + ) { + self.channel = channel + self.meta = meta + } +} + +public struct ResolveCanvasSourceResult: Codable, Sendable { + /// Current {@link CanvasEntry.availability}. + public var availability: CanvasAvailabilityStatus + /// Current {@link CanvasIdentity.incarnation}. + public var incarnation: String + /// Current {@link CanvasEntry.revision}. + public var revision: Int + /// Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. + public var source: CanvasSourcePresentation? + + public init( + availability: CanvasAvailabilityStatus, + incarnation: String, + revision: Int, + source: CanvasSourcePresentation? = nil + ) { + self.availability = availability + self.incarnation = incarnation + self.revision = revision + self.source = source + } +} + +public struct InvokeCanvasActionParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. + public var actionId: String + /// Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + /// if any. The JSON-serialized value MUST NOT exceed + /// `CANVAS_INPUT_MAX_LENGTH`. + public var input: AnyCodable? + /// Expected {@link CanvasIdentity.incarnation}. Required — see above. The + /// server MUST reject the call with `Conflict` if the canvas's live + /// endpoint has since been superseded, rather than deliver the call to it. + public var incarnation: String + /// Durable client-generated idempotency key bounding retry + /// deduplication for this invocation within a live window. The server is + /// not required to guarantee exactly-once execution across a crash. MUST + /// NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case actionId + case input + case incarnation + case requestId + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + actionId: String, + input: AnyCodable? = nil, + incarnation: String, + requestId: String + ) { + self.channel = channel + self.meta = meta + self.actionId = actionId + self.input = input + self.incarnation = incarnation + self.requestId = requestId + } +} + +public struct InvokeCanvasActionResult: Codable, Sendable { + /// The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. + public var result: AnyCodable + + public init( + result: AnyCodable + ) { + self.result = result + } +} + +public struct RestartCanvasProviderParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + /// Expected current {@link CanvasIdentity.incarnation}; required — see above. + public var incarnation: String + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case requestId + case incarnation + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + requestId: String, + incarnation: String + ) { + self.channel = channel + self.meta = meta + self.requestId = requestId + self.incarnation = incarnation + } +} + +public struct CloseCanvasParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Durable client-generated idempotency key, following the same + /// requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + /// `CANVAS_REQUEST_ID_MAX_LENGTH`. + public var requestId: String + /// Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. + public var revision: Int + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case requestId + case revision + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + requestId: String, + revision: Int + ) { + self.channel = channel + self.meta = meta + self.requestId = requestId + self.revision = revision + } +} + // MARK: - Command Unions public enum ChatSource: Codable, Sendable { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 313783d6..f855914e 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1101,6 +1101,129 @@ public enum AutomationRunOriginKind: String, Codable, Sendable { case trigger = "trigger" } +/// Discriminant for {@link CanvasSource} — what kind of package originates a +/// canvas type. +public enum CanvasSourceKind: Codable, Sendable, Equatable { + /// An explicitly installed host extension. + case `extension` + /// An explicitly installed package (not a host extension). + case package + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "extension": self = .`extension` + case "package": self = .package + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .`extension`: try container.encode("extension") + case .package: try container.encode("package") + case .unknown(let raw): try container.encode(raw) + } + } +} + +/// Discriminant for {@link CanvasTrustState} — whether the host currently +/// permits this canvas's declared actions to execute. +/// +/// Trust is independent of {@link CanvasAvailabilityStatus | availability}: +/// a canvas may be perfectly capable of rendering while blocked from +/// executing actions, and vice versa. Trust decisions are host/runtime +/// authority, not something this protocol grants. +public enum CanvasTrustStatus: Codable, Sendable, Equatable { + /// Declared actions may be invoked. + case trusted + /// A trust decision has not yet been made (e.g. first use of a new/changed source). + case pending + /// The host has denied execution; declared actions MUST NOT be invoked. + case blocked + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "trusted": self = .trusted + case "pending": self = .pending + case "blocked": self = .blocked + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .trusted: try container.encode("trusted") + case .pending: try container.encode("pending") + case .blocked: try container.encode("blocked") + case .unknown(let raw): try container.encode(raw) + } + } +} + +/// Discriminant for {@link CanvasAvailabilityState} — the canvas's current +/// live resolution state, independent of its durable +/// {@link CanvasEntry | membership} in a session's catalog. +/// +/// An empty catalog membership list is not itself a close, and a canvas may +/// remain a recorded member while its live availability cycles through these +/// states any number of times (e.g. across provider restarts). +public enum CanvasAvailabilityStatus: Codable, Sendable, Equatable { + /// The connected client or host does not support this canvas type (e.g. + /// the client omitted the `canvases` capability, or no local runtime can + /// render this `canvasType`). Distinct from `blocked` trust, which is a + /// policy decision rather than a capability gap. + case unsupported + /// Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. + case notLoaded + /// Currently resolving or (re)connecting to a live endpoint. + case loading + /// Live and reachable, but the provider has not yet produced content to render. + case empty + /// Live, reachable, and has declared its current actions. + case ready + /// The live endpoint failed to resolve, or resolution otherwise failed. + case failed + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "unsupported": self = .unsupported + case "notLoaded": self = .notLoaded + case "loading": self = .loading + case "empty": self = .empty + case "ready": self = .ready + case "failed": self = .failed + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .unsupported: try container.encode("unsupported") + case .notLoaded: try container.encode("notLoaded") + case .loading: try container.encode("loading") + case .empty: try container.encode("empty") + case .ready: try container.encode("ready") + case .failed: try container.encode("failed") + case .unknown(let raw): try container.encode(raw) + } + } +} + // MARK: - State Types public struct Icon: Codable, Sendable { @@ -1867,6 +1990,15 @@ public struct SessionState: Codable, Sendable { /// before subscribing. See {@link Changeset} for the full shape and /// {@link /guide/changesets | Changesets} for an overview of the model. public var changesets: [Changeset]? + /// Catalog of canvases opened for chats in this session. Presence is + /// durable logical membership, admitted via `openCanvas` or host publication + /// of a correlated, already-open native instance under that command's + /// admission rules. Membership is never implied by discovery, subscription, + /// source resolution, a chat's existence, or a client's earlier focus. + /// Each entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the + /// exact backing chat; a canvas never migrates to a different chat. See + /// {@link CanvasEntry} for the full membership/availability/trust model. + public var canvases: [CanvasEntry]? /// Outstanding input the session is blocked on, aggregated across every chat /// so a client can discover and answer it from the session channel alone, /// without subscribing to individual chats. @@ -1910,6 +2042,7 @@ public struct SessionState: Codable, Sendable { case config case customizations case changesets + case canvases case inputNeeded case meta = "_meta" } @@ -1932,6 +2065,7 @@ public struct SessionState: Codable, Sendable { config: SessionConfigState? = nil, customizations: [Customization]? = nil, changesets: [Changeset]? = nil, + canvases: [CanvasEntry]? = nil, inputNeeded: [SessionInputRequest]? = nil, meta: [String: AnyCodable]? = nil ) { @@ -1952,6 +2086,7 @@ public struct SessionState: Codable, Sendable { self.config = config self.customizations = customizations self.changesets = changesets + self.canvases = canvases self.inputNeeded = inputNeeded self.meta = meta } @@ -6627,6 +6762,448 @@ public struct AutomationRunState: Codable, Sendable { } } +public struct CanvasExtensionSource: Codable, Sendable { + public var kind: CanvasSourceKind + /// Stable extension identifier (host-defined format, e.g. `publisher.name`). + /// MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var extensionId: String + /// Installed extension version, when known. Metadata only — not identity-bearing. + public var version: String? + + public init( + kind: CanvasSourceKind, + extensionId: String, + version: String? = nil + ) { + self.kind = kind + self.extensionId = extensionId + self.version = version + } +} + +public struct CanvasPackageSource: Codable, Sendable { + public var kind: CanvasSourceKind + /// Stable, host- or package-manager-assigned unique identifier for this + /// specific installed package instance/scope (opaque format). This is the + /// identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var sourceId: String + /// Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. + public var packageName: String + /// Installed package version, when known. Metadata only — not identity-bearing. + public var version: String? + + public init( + kind: CanvasSourceKind, + sourceId: String, + packageName: String, + version: String? = nil + ) { + self.kind = kind + self.sourceId = sourceId + self.packageName = packageName + self.version = version + } +} + +public struct CanvasIdentityKey: Codable, Sendable { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public var chat: String + /// The extension or package that declares this canvas's type. + public var source: CanvasSource + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var canvasType: String + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var instanceId: String + + public init( + chat: String, + source: CanvasSource, + canvasType: String, + instanceId: String + ) { + self.chat = chat + self.source = source + self.canvasType = canvasType + self.instanceId = instanceId + } +} + +public struct CanvasIdentity: Codable, Sendable { + /// The exact backing chat this canvas belongs to. A canvas is never + /// re-associated with a different chat; opening a new one for another chat + /// creates a distinct canvas. + public var chat: String + /// The extension or package that declares this canvas's type. + public var source: CanvasSource + /// Provider-declared canvas type (host/provider-defined format). MUST NOT + /// exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var canvasType: String + /// Provider-chosen stable identifier for this canvas instance, scoped to + /// `(chat, source, canvasType)`. Stable across reloads and host/window + /// restarts for the same logical canvas. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var instanceId: String + /// Opaque, host-generated token identifying the current generation of this + /// canvas's live endpoint. The host mints a fresh token whenever a provider + /// restart retires the previous live endpoint and establishes a new one for + /// the same logical instance (see {@link CanvasIncarnationChangedAction | + /// `canvas/incarnationChanged`}); it is not changed by a plain page reload + /// or transient presentation credential renewal for the same still-live + /// endpoint. + /// + /// `incarnation` is **opaque**: clients and hosts MUST compare it only for + /// equality, never parse it, sort it, or perform arithmetic on it (e.g. it + /// is not guaranteed to be numeric or monotonically increasing). The host + /// MUST NOT reuse a token for this logical identity once it has been + /// superseded, including across a host/process restart — if the host + /// cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + /// or timestamp-derived) that make accidental reuse practically + /// impossible, rather than a small resettable counter. + /// + /// Clients and hosts use `incarnation` to reject stale callbacks and + /// in-flight effects addressed to a superseded endpoint. + public var incarnation: String + + public init( + chat: String, + source: CanvasSource, + canvasType: String, + instanceId: String, + incarnation: String + ) { + self.chat = chat + self.source = source + self.canvasType = canvasType + self.instanceId = instanceId + self.incarnation = incarnation + } +} + +public struct CanvasTrustedState: Codable, Sendable { + public var status: CanvasTrustStatus + + public init( + status: CanvasTrustStatus + ) { + self.status = status + } +} + +public struct CanvasPendingTrustState: Codable, Sendable { + public var status: CanvasTrustStatus + + public init( + status: CanvasTrustStatus + ) { + self.status = status + } +} + +public struct CanvasBlockedTrustState: Codable, Sendable { + public var status: CanvasTrustStatus + /// Optional human-readable reason surfaced to the user. + public var reason: String? + + public init( + status: CanvasTrustStatus, + reason: String? = nil + ) { + self.status = status + self.reason = reason + } +} + +public struct CanvasActionDeclaration: Codable, Sendable { + /// Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. + public var id: String + /// Human-readable display name. + public var title: String? + /// Description of what invoking the action does. + public var description: String? + /// Inline JSON Schema for the expected `input`, when small enough to embed + /// (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + /// checked by {@link isCanvasSchemaWithinLimits}). Optional because some + /// declared actions take no input. Mutually exclusive with + /// `inputSchemaRef` — a declaration MUST supply at most one of the two. + public var inputSchema: AnyCodable? + /// Bounded out-of-band reference to a larger JSON Schema, used instead of + /// `inputSchema` when the schema would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. AHP does not mandate a specific resolution mechanism for this + /// URI (e.g. a host MAY make it `resourceRead`-able). + public var inputSchemaRef: String? + + public init( + id: String, + title: String? = nil, + description: String? = nil, + inputSchema: AnyCodable? = nil, + inputSchemaRef: String? = nil + ) { + self.id = id + self.title = title + self.description = description + self.inputSchema = inputSchema + self.inputSchemaRef = inputSchemaRef + } +} + +public struct CanvasUnsupportedAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasNotLoadedAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasLoadingAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasEmptyAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + + public init( + status: CanvasAvailabilityStatus + ) { + self.status = status + } +} + +public struct CanvasReadyAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + /// Actions currently declared by the live provider (full replacement each time this state is produced). + public var actions: [CanvasActionDeclaration] + + public init( + status: CanvasAvailabilityStatus, + actions: [CanvasActionDeclaration] + ) { + self.status = status + self.actions = actions + } +} + +public struct CanvasFailedAvailabilityState: Codable, Sendable { + public var status: CanvasAvailabilityStatus + /// Stable machine-readable and human-readable failure information. + public var error: ErrorInfo + + public init( + status: CanvasAvailabilityStatus, + error: ErrorInfo + ) { + self.status = status + self.error = error + } +} + +public struct CanvasEntry: Codable, Sendable { + /// Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. + public var resource: String + /// Full identity, including current incarnation. + public var identity: CanvasIdentity + /// Human-readable display title. + public var title: String + /// Optional display icon. + public var icon: Icon? + /// Current trust decision matching {@link CanvasState.trust}. + public var trust: CanvasTrustState + /// Current availability status matching {@link CanvasState.availability}'s discriminant. + public var availability: CanvasAvailabilityStatus + /// Monotonically increasing counter bumped on every change to this + /// canvas's state (trust, availability, or incarnation). Clients MAY use it + /// to detect and reject stale reads without a full deep comparison. + /// Transient presentation credential renewal alone does not require a + /// revision change. + public var revision: Int + /// Opaque host-defined summary metadata. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case resource + case identity + case title + case icon + case trust + case availability + case revision + case meta = "_meta" + } + + public init( + resource: String, + identity: CanvasIdentity, + title: String, + icon: Icon? = nil, + trust: CanvasTrustState, + availability: CanvasAvailabilityStatus, + revision: Int, + meta: [String: AnyCodable]? = nil + ) { + self.resource = resource + self.identity = identity + self.title = title + self.icon = icon + self.trust = trust + self.availability = availability + self.revision = revision + self.meta = meta + } +} + +public struct CanvasState: Codable, Sendable { + /// URI of this canvas channel. + public var resource: String + /// Full identity, including current incarnation. + public var identity: CanvasIdentity + /// Human-readable display title. + public var title: String + /// Optional display icon. + public var icon: Icon? + /// Current trust decision. + public var trust: CanvasTrustState + /// Current live resolution state. + public var availability: CanvasAvailabilityState + /// Matches {@link CanvasEntry.revision}. + public var revision: Int + /// Opaque host-defined metadata. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case resource + case identity + case title + case icon + case trust + case availability + case revision + case meta = "_meta" + } + + public init( + resource: String, + identity: CanvasIdentity, + title: String, + icon: Icon? = nil, + trust: CanvasTrustState, + availability: CanvasAvailabilityState, + revision: Int, + meta: [String: AnyCodable]? = nil + ) { + self.resource = resource + self.identity = identity + self.title = title + self.icon = icon + self.trust = trust + self.availability = availability + self.revision = revision + self.meta = meta + } +} + +public struct CanvasTypeDeclaration: Codable, Sendable { + /// The extension or package that declares this canvas type. + public var source: CanvasSource + /// Provider-declared canvas type (host/provider-defined format), passed as + /// {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + /// {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + public var canvasType: String + /// Human-readable display name for a canvas-type picker. + public var title: String + /// Description of what this canvas type does. + public var description: String? + /// Optional display icon. + public var icon: Icon? + /// Inline JSON Schema describing the `openCanvas` `input` this type + /// expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + /// / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + /// `openInputSchemaRef`. + public var openInputSchema: AnyCodable? + /// Bounded out-of-band reference to a larger open-input JSON Schema, used + /// instead of `openInputSchema` when it would exceed + /// {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + /// inlined. + public var openInputSchemaRef: String? + /// Advisory, statically-known preview of actions this canvas type + /// typically declares once opened (bounded to + /// {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + /// the actual invocable actions for an opened instance are always + /// {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + /// depend on live provider configuration) and MUST be used instead of this + /// preview once the canvas is open. + public var declaredActions: [CanvasActionDeclaration]? + + public init( + source: CanvasSource, + canvasType: String, + title: String, + description: String? = nil, + icon: Icon? = nil, + openInputSchema: AnyCodable? = nil, + openInputSchemaRef: String? = nil, + declaredActions: [CanvasActionDeclaration]? = nil + ) { + self.source = source + self.canvasType = canvasType + self.title = title + self.description = description + self.icon = icon + self.openInputSchema = openInputSchema + self.openInputSchemaRef = openInputSchemaRef + self.declaredActions = declaredActions + } +} + +public struct CanvasSourcePresentation: Codable, Sendable { + /// Ephemeral URL to the canvas's current live endpoint. Transient: MUST + /// NOT be persisted (including durable canvas/session state or editor + /// restoration data), written to routine logs, or treated as a stable + /// identity. A host MAY embed + /// short-lived, single-use credentials in it; such credentials are never + /// durable authority. Renewed credentials MAY produce a different URL for + /// the same incarnation and revision. Reuse is safe only while the + /// credential is known to remain valid and reusable. + public var url: String + /// Advisory expiry hint for `url` (and any embedded credential), when + /// known. Omission does not imply indefinite validity or reusability, and + /// an unexpired credential may still be single-use. + public var expiresAt: String? + + public init( + url: String, + expiresAt: String? = nil + ) { + self.url = url + self.expiresAt = expiresAt + } +} + // MARK: - Customization Enablement Union /// A single explicit customization enablement decision. @@ -7662,6 +8239,156 @@ public enum AutomationRunLifecycle: Codable, Sendable { } } +public enum CanvasSource: Codable, Sendable { + case `extension`(CanvasExtensionSource) + case package(CanvasPackageSource) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "extension": + self = .`extension`(try CanvasExtensionSource(from: decoder)) + case "package": + self = .package(try CanvasPackageSource(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .`extension`(var value): + value.kind = .`extension` + try value.encode(to: encoder) + case .package(var value): + value.kind = .package + try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + +public enum CanvasTrustState: Codable, Sendable { + case trusted(CanvasTrustedState) + case pending(CanvasPendingTrustState) + case blocked(CanvasBlockedTrustState) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "status" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "trusted": + self = .trusted(try CanvasTrustedState(from: decoder)) + case "pending": + self = .pending(try CanvasPendingTrustState(from: decoder)) + case "blocked": + self = .blocked(try CanvasBlockedTrustState(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .trusted(var value): + value.status = .trusted + try value.encode(to: encoder) + case .pending(var value): + value.status = .pending + try value.encode(to: encoder) + case .blocked(var value): + value.status = .blocked + try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + +public enum CanvasAvailabilityState: Codable, Sendable { + case unsupported(CanvasUnsupportedAvailabilityState) + case notLoaded(CanvasNotLoadedAvailabilityState) + case loading(CanvasLoadingAvailabilityState) + case empty(CanvasEmptyAvailabilityState) + case ready(CanvasReadyAvailabilityState) + case failed(CanvasFailedAvailabilityState) + /// Unknown or future discriminant; the raw payload is preserved + /// and re-encoded verbatim for forward-compatibility. + case unknown(AnyCodable) + + private enum DiscriminantKey: String, CodingKey { + case discriminant = "status" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + guard let discriminant = try container.decodeIfPresent(String.self, forKey: .discriminant) else { + self = .unknown(try AnyCodable(from: decoder)) + return + } + switch discriminant { + case "unsupported": + self = .unsupported(try CanvasUnsupportedAvailabilityState(from: decoder)) + case "notLoaded": + self = .notLoaded(try CanvasNotLoadedAvailabilityState(from: decoder)) + case "loading": + self = .loading(try CanvasLoadingAvailabilityState(from: decoder)) + case "empty": + self = .empty(try CanvasEmptyAvailabilityState(from: decoder)) + case "ready": + self = .ready(try CanvasReadyAvailabilityState(from: decoder)) + case "failed": + self = .failed(try CanvasFailedAvailabilityState(from: decoder)) + default: + self = .unknown(try AnyCodable(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .unsupported(var value): + value.status = .unsupported + try value.encode(to: encoder) + case .notLoaded(var value): + value.status = .notLoaded + try value.encode(to: encoder) + case .loading(var value): + value.status = .loading + try value.encode(to: encoder) + case .empty(var value): + value.status = .empty + try value.encode(to: encoder) + case .ready(var value): + value.status = .ready + try value.encode(to: encoder) + case .failed(var value): + value.status = .failed + try value.encode(to: encoder) + case .unknown(let value): try value.encode(to: encoder) + } + } +} + public enum ToolResultContent: Codable, Sendable { case text(ToolResultTextContent) case embeddedResource(ToolResultEmbeddedResourceContent) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index b8527850..35825e38 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.9.0" +public let PROTOCOL_VERSION: String = "0.10.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first. The first entry equals @@ -13,6 +13,7 @@ public let PROTOCOL_VERSION: String = "0.9.0" /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 0d5585cf..55bc453f 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -709,6 +709,28 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS } return next + case .sessionCanvasSet(let a): + var next = state + var canvases = next.canvases ?? [] + if let idx = canvases.firstIndex(where: { $0.resource == a.canvas.resource }) { + guard a.canvas.revision > canvases[idx].revision else { return state } + canvases[idx] = a.canvas + } else { + canvases.append(a.canvas) + } + next.canvases = canvases + return next + + case .sessionCanvasRemoved(let a): + guard var canvases = state.canvases, + let idx = canvases.firstIndex(where: { $0.resource == a.resource }) else { + return state + } + canvases.remove(at: idx) + var next = state + next.canvases = canvases + return next + case .sessionChatRemoved(let a): guard let idx = state.chats.firstIndex(where: { $0.resource == a.chat }) else { return state @@ -1491,6 +1513,36 @@ public func resourceWatchReducer(state: ResourceWatchState, action: StateAction) } } +/// Pure reducer for canvas state. Rejects stale or duplicate revisions. +public func canvasReducer(state: CanvasState, action: StateAction) -> CanvasState { + var next = state + switch action { + case .canvasAvailabilityChanged(let value): + guard value.revision > state.revision else { return state } + next.availability = value.availability + next.revision = value.revision + case .canvasTrustChanged(let value): + guard value.revision > state.revision else { return state } + next.trust = value.trust + next.revision = value.revision + case .canvasIncarnationChanged(let value): + guard value.revision > state.revision else { return state } + next.identity.incarnation = value.incarnation + next.revision = value.revision + case .canvasTitleChanged(let value): + guard value.revision > state.revision else { return state } + next.title = value.title + next.revision = value.revision + case .canvasIconChanged(let value): + guard value.revision > state.revision else { return state } + next.icon = value.icon + next.revision = value.revision + default: + return state + } + return next +} + /// Pure reducer for automation catalogue state. public func automationReducer(state: AutomationState, action: StateAction) -> AutomationState { var next = state diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift index 100ef243..f2c6526f 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift @@ -188,6 +188,10 @@ final class FixtureDrivenReducerTests: XCTestCase { try compareFixture(file: file, fixture: fixture, stateType: ResourceWatchState.self) { state in actions.reduce(state) { resourceWatchReducer(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) } + } case "chat": try compareFixture(file: file, fixture: fixture, stateType: ChatState.self) { state in actions.reduce(state) { chatReducer(state: $0, action: $1) } diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index e5693c0d..485b6ec3 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,6 +2,7 @@ "client": "swift", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index 1925c929..c7d90aa5 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,6 +2,7 @@ "client": "typescript", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/docs/.changes/20260909-local-canvas-protocol.json b/docs/.changes/20260909-local-canvas-protocol.json new file mode 100644 index 00000000..6ad74993 --- /dev/null +++ b/docs/.changes/20260909-local-canvas-protocol.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`ahp-canvas:` channel for local canvases: stable `CanvasIdentity` (exact backing chat, extension/package source with a dedicated stable `sourceId`, canvas type, instance, and an opaque host-generated `incarnation` token), a `CanvasTrustState`/`CanvasAvailabilityState` lifecycle with consistent stale-revision rejection, `SessionState.canvases` catalogue membership distinct from the new read-only `listCanvasTypes` type-discovery catalogue, and `openCanvas`/`resolveCanvasSource`/`invokeCanvasAction`/`restartCanvasProvider`/`closeCanvas` commands with requestId-scoped idempotency, required incarnation/revision preconditions, and declared size/depth limits (`CANVAS_SCHEMA_MAX_PROPERTIES`, `CANVAS_SCHEMA_MAX_DEPTH`, `CANVAS_INPUT_MAX_LENGTH`, `CANVAS_RESULT_MAX_LENGTH`). Adds a client-side `ClientCapabilities.canvases` rendering capability and a separate server/runtime-side `InitializeResult.canvases` (`CanvasCapabilities`) capability, so protocol version support alone never implies a usable canvas runtime." +} diff --git a/docs/.changes/20260911-canvas-admission-and-presentation.json b/docs/.changes/20260911-canvas-admission-and-presentation.json new file mode 100644 index 00000000..0a66a16c --- /dev/null +++ b/docs/.changes/20260911-canvas-admission-and-presentation.json @@ -0,0 +1,4 @@ +{ + "type": "changed", + "message": "Clarify host publication of correlated, already-open native canvases without reopening providers, and transient presentation credential renewal through `resolveCanvasSource` without changing incarnation or requiring a state revision." +} diff --git a/docs/.changes/20260912-canvas-icon-changed.json b/docs/.changes/20260912-canvas-icon-changed.json new file mode 100644 index 00000000..5af7388b --- /dev/null +++ b/docs/.changes/20260912-canvas-icon-changed.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`canvas/iconChanged` now updates or removes authoritative canvas display icons with revision ordering." +} diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 70f7a2b5..7b97a582 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -351,6 +351,40 @@ "type" ] }, + "SessionCanvasSetAction": { + "type": "object", + "description": "A canvas was admitted (opened) or its catalog entry changed.\n\nUpsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the\nserver dispatches this with the full entry to record a newly opened\ncanvas, or to republish it after a trust/availability/incarnation change\nso subscribers following only the session channel stay in sync with\n{@link CanvasState}. Never client-dispatchable: admission is through\n`openCanvas` or host publication of a correlated, already-open native\ninstance under that command's admission rules. Both paths MUST use the\nsame singular identity-to-resource binding; repeated native observations\nMUST NOT create a second entry. A stale/out-of-order delivery\n(`canvas.revision` not strictly greater than the currently-recorded entry's\nrevision) MUST be rejected (no-op) rather than overwrite a newer entry with\nolder data.", + "properties": { + "type": { + "const": "session/canvasSet" + }, + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The canvas entry to add or update, matched by `resource`." + } + }, + "required": [ + "type", + "canvas" + ] + }, + "SessionCanvasRemovedAction": { + "type": "object", + "description": "A canvas was logically closed.\n\nRemove semantics keyed by `resource`: an unknown URI is a no-op. This\nrepresents durable membership removal, not a client hiding a local\ntab/view — see `closeCanvas`.", + "properties": { + "type": { + "const": "session/canvasRemoved" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}." + } + }, + "required": [ + "type", + "resource" + ] + }, "SessionServerToolsChangedAction": { "type": "object", "description": "Server tools for this session have changed.\n\nFull-replacement semantics: the `tools` array replaces the previous `serverTools` entirely.", @@ -2316,6 +2350,123 @@ "type" ] }, + "CanvasAvailabilityChangedAction": { + "type": "object", + "description": "Replaces the canvas's live resolution state.\n\nDispatched by the host on every availability transition, including\ninitial resolution after admission by `openCanvas` or a correlated native\nopen, provider restart, and endpoint failure/recovery. A client-local page\nreload or transient presentation credential renewal alone does not require\nthis action or a revision change.", + "properties": { + "type": { + "const": "canvas/availabilityChanged" + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "New {@link CanvasState.availability}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in. The reducer\nMUST reject (no-op) this action if `revision` is not strictly greater\nthan the canvas's current `revision` — this is how stale/out-of-order\ndeliveries are consistently rejected across every canvas action, not\njust this one." + } + }, + "required": [ + "type", + "availability", + "revision" + ] + }, + "CanvasTrustChangedAction": { + "type": "object", + "description": "Replaces the canvas's trust decision.\n\nDispatched by the host whenever the execution-trust decision for this\ncanvas's declared actions changes (e.g. a pending decision resolves, or an\nadministrator revokes a previously trusted source).", + "properties": { + "type": { + "const": "canvas/trustChanged" + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "New {@link CanvasState.trust}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "trust", + "revision" + ] + }, + "CanvasIncarnationChangedAction": { + "type": "object", + "description": "Records that the canvas's live endpoint was replaced by a fresh one for\nthe same logical instance (e.g. the owning provider restarted).\n\nRenewing transient presentation credentials for the same live endpoint is\nnot endpoint replacement and MUST NOT trigger this action.\n\nThe host MUST dispatch {@link CanvasAvailabilityChangedAction} to\ntransition through `notLoaded`/`loading` around this change. Receivers\nMUST reject in-flight `invokeCanvasAction` replies and stale server-pushed\ncallbacks addressed to a superseded `incarnation` — because `incarnation`\nis opaque (see {@link CanvasIdentity.incarnation}), that rejection is\ndriven by the accompanying `revision` bump here, not by comparing\n`incarnation` values for order.", + "properties": { + "type": { + "const": "canvas/incarnationChanged" + }, + "incarnation": { + "type": "string", + "description": "New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "incarnation", + "revision" + ] + }, + "CanvasTitleChangedAction": { + "type": "object", + "description": "Replaces the canvas's display title.", + "properties": { + "type": { + "const": "canvas/titleChanged" + }, + "title": { + "type": "string", + "description": "New {@link CanvasState.title}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "title", + "revision" + ] + }, + "CanvasIconChangedAction": { + "type": "object", + "description": "Replaces or removes the canvas's display icon.\n\nThis is presentation metadata only. It does not replace the live endpoint,\nchange the canvas incarnation, or replay any canvas effect.", + "properties": { + "type": { + "const": "canvas/iconChanged" + }, + "icon": { + "oneOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "type": "null" + } + ], + "description": "New {@link CanvasState.icon}; `null` removes the current icon." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "icon", + "revision" + ] + }, "ChatToolCallConfirmedAction": { "oneOf": [ { @@ -2840,6 +2991,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -3229,6 +3383,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted via `openCanvas` or host publication\nof a correlated, already-open native instance under that command's\nadmission rules. Membership is never implied by discovery, subscription,\nsource resolution, a chat's existence, or a client's earlier focus.\nEach entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the\nexact backing chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -7984,6 +8145,462 @@ "sessions" ] }, + "CanvasExtensionSource": { + "type": "object", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", + "properties": { + "kind": { + "const": "extension" + }, + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "extensionId" + ] + }, + "CanvasPackageSource": { + "type": "object", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", + "properties": { + "kind": { + "const": "package" + }, + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." + }, + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "sourceId", + "packageName" + ] + }, + "CanvasIdentityKey": { + "type": "object", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.\n\nThis logical tuple does not widen the owning runtime's native instance-ID\nnamespace. A runtime may require session-wide native IDs across providers;\nhosts MUST preserve that constraint rather than hide native collisions\nwith an invented provider namespace.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId" + ] + }, + "CanvasIdentity": { + "type": "object", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nor transient presentation credential renewal for the same still-live\nendpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) MUST NOT execute or start a provider,\nor open, materialize, or admit a canvas. Membership requires `openCanvas`\nor host publication of a correlated, already-open native instance under\nthat command's admission rules.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient: MUST\nNOT be persisted (including durable canvas/session state or editor\nrestoration data), written to routine logs, or treated as a stable\nidentity. A host MAY embed\nshort-lived, single-use credentials in it; such credentials are never\ndurable authority. Renewed credentials MAY produce a different URL for\nthe same incarnation and revision. Reuse is safe only while the\ncredential is known to remain valid and reusable." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), when\nknown. Omission does not imply indefinite validity or reusability, and\nan unexpired credential may still be single-use." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nMembership is admitted by `openCanvas` or by host publication of a\ncorrelated, already-open native instance under that command's admission\nrules, never by discovery, subscription, or source resolution.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison.\nTransient presentation credential renewal alone does not require a\nrevision change." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -8529,6 +9146,54 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "StateAction": { "description": "Discriminated union of all state actions.", "oneOf": [ @@ -8631,6 +9296,12 @@ { "$ref": "#/$defs/SessionMetaChangedAction" }, + { + "$ref": "#/$defs/SessionCanvasSetAction" + }, + { + "$ref": "#/$defs/SessionCanvasRemovedAction" + }, { "$ref": "#/$defs/ChatTurnStartedAction" }, @@ -8822,6 +9493,21 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/CanvasAvailabilityChangedAction" + }, + { + "$ref": "#/$defs/CanvasTrustChangedAction" + }, + { + "$ref": "#/$defs/CanvasIncarnationChangedAction" + }, + { + "$ref": "#/$defs/CanvasTitleChangedAction" + }, + { + "$ref": "#/$defs/CanvasIconChangedAction" } ] }, @@ -9000,6 +9686,18 @@ ], "type": "string", "description": "Operations the host currently permits for an automation.\n\nThe list on {@link AutomationEntry.operations} is authoritative and may\nchange over time. Clients MUST NOT infer permission from capabilities alone:\ncapabilities describe what the host implementation can support, while\noperations describe what is allowed for this particular automation now." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." } } } diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 22902368..cbbe034e 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -128,6 +128,11 @@ "type": "object", "additionalProperties": {}, "description": "Client can render\n[MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e.\nit can host the View sandbox, run the `ui/*` protocol against it,\nand forward `mcp://`-channel traffic on the App's behalf.\n\nHosts SHOULD only populate\n{@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`}\n(and expose the corresponding\n{@link McpServerCustomization.channel | `mcp://` channel}) when this\ncapability is declared. Clients that omit it MUST treat\nApp-bearing tool calls as ordinary MCP tool calls." + }, + "canvases": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." } } }, @@ -181,6 +186,10 @@ "automations": { "$ref": "#/$defs/AutomationCapabilities", "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + }, + "canvases": { + "$ref": "#/$defs/CanvasCapabilities", + "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking protocol `>= 0.10.0` without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." } }, "required": [ @@ -189,6 +198,11 @@ "snapshots" ] }, + "CanvasCapabilities": { + "type": "object", + "description": "Local-canvas runtime features supported by this host authority. The empty\nobject means \"supported\" — see {@link InitializeResult.canvases} for what\npresence/absence of this field itself means.", + "properties": {} + }, "AutomationCapabilities": { "type": "object", "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", @@ -1665,6 +1679,259 @@ "description": "Empty acknowledgement; the updated automation state is delivered by action.", "properties": {} }, + "ListCanvasTypesParams": { + "type": "object", + "description": "Discovers canvas TYPES currently available to open for one exact backing\nchat.\n\nThis is a **pure read/browse** operation: it MUST NOT execute or start a\nprovider, or open, materialize, or otherwise admit any canvas. See\n`openCanvas` for the admission rules, including publication of an\nalready-open native instance. This catalogue is unrelated to\n{@link SessionState.canvases}, which reflects durable membership of\nalready-opened canvas INSTANCES, not the set of canvas TYPES a\nhost/extension could open; do not confuse the two.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat to discover available canvas types for." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "limit": { + "type": "number", + "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." + } + }, + "required": [ + "channel" + ] + }, + "ListCanvasTypesResult": { + "type": "object", + "description": "Available canvas types for the requested chat.", + "properties": { + "nextCursor": { + "type": "string", + "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." + }, + "types": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasTypeDeclaration" + }, + "description": "Discovered canvas type declarations." + } + }, + "required": [ + "types" + ] + }, + "OpenCanvasParams": { + "type": "object", + "description": "Explicitly opens (admits) a canvas, associating it with the owning chat\ngiven by `identity.chat` at the moment of the call — never with whichever\nchat later happens to have focus.\n\nCanvas membership requires explicit admission. A client admits a canvas\nby calling `openCanvas`, a read-write operation. `listCanvasTypes`,\n`subscribe`, and `resolveCanvasSource` MUST NOT admit a canvas or execute\nor start its provider.\n\nA host MAY also publish membership after observing an instance already\nopened by the owning native runtime. Before publication, the host MUST\ncorrelate the observation to the actual backing chat, canonical source,\ncanvas type, and native instance, and enforce applicable\nexecution-admission policy. Uncorrelated or conflicting observations MUST\nbe rejected rather than assigned to the focused chat or a guessed source.\nObservation does not grant execution trust: it MUST NOT convert `pending`\nor `blocked` trust to `trusted`; trust and availability remain independent.\n\nNative publication follows the same singular identity-to-resource binding\nand authoritative state rules as client-originated admission. The host\nMUST NOT manufacture a client `openCanvas` request or invoke the provider's\nopen handler again merely to publish an already-open instance. Repeated\nobservations MUST NOT duplicate membership; this does not suppress the\nactual effects of a genuinely new native open. Hosts MUST preserve the\nnative instance-ID namespace, including session-wide IDs across providers\nwhere the owning runtime requires them, rather than hide collisions with\nan invented provider namespace. Client `requestId` semantics are unchanged.\n\nOnce admitted by either path, clients read and follow live state by\n`subscribe`-ing to `canvas.resource`, and resolve the current live endpoint\nvia `resolveCanvasSource`; neither read opens, resumes, or restarts anything.\n\n**Logical identity is always singular.** The same {@link CanvasIdentityKey}\n(`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same\n`canvas` resource URI and the same {@link SessionState.canvases} catalog\nentry, no matter how many times `openCanvas` is called or a native open is\nobserved for it. The server MUST reuse that existing entry's `resource`\nrather than mint a second one. A client-supplied `canvas` URI is honored\nonly on the call that first establishes the identity; on a later call for\nan already-recorded identity the server MUST ignore the supplied `canvas`\nvalue and return the existing resource instead.\n\n**Idempotency is scoped to `requestId`, not identity.** Retrying with the\nexact same `requestId` and byte-for-byte identical params from the same\nauthenticated connection MUST return the original result without\nrepeating any side effect, within a bounded live window (the server is\nnot required to remember it forever). Reusing the same `requestId` with\nany different parameter value MUST be rejected with `Conflict`\n(`-32011`) — mint a new `requestId` for a new logical call. A genuinely\nNEW `requestId` for an already-open identity MAY be effectful (e.g.\nupdating `title`/`icon`, or causing the provider to re-run its own\nopen-time initialization with new `input`) — this mirrors the pinned\nSDK's own repeated-open behavior and does not create a second logical\nidentity. There is no exactly-once-across-crash guarantee: a lost reply\nis indeterminate, and clients MUST NOT automatically replay `openCanvas`\n— reconnect and read `SessionState.canvases` / `resolveCanvasSource`\ninstead to determine the actual outcome.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Session URI that will list the opened canvas in `SessionState.canvases`." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "canvas": { + "$ref": "#/$defs/URI", + "description": "Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentityKey", + "description": "Logical identity to open or re-admit." + }, + "title": { + "type": "string", + "description": "Initial (or updated, on a later effectful call) display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Initial (or updated) display icon." + }, + "input": { + "description": "Bounded JSON input for this open call (e.g. seed parameters the\nprovider uses to initialize the canvas), opaque to the protocol. See\n{@link CanvasTypeDeclaration.openInputSchema} /\n`openInputSchemaRef` for the expected shape. The JSON-serialized value\nMUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key bounding retry deduplication\nfor this call within a live window; see the idempotency rules above.\nMUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." + } + }, + "required": [ + "channel", + "canvas", + "identity", + "title", + "requestId" + ] + }, + "OpenCanvasResult": { + "type": "object", + "description": "Result identifying the existing or newly opened canvas.", + "properties": { + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The catalog entry for the opened (or already-open) canvas." + } + }, + "required": [ + "canvas" + ] + }, + "ResolveCanvasSourceParams": { + "type": "object", + "description": "Reads a canvas's current live-resolution state and, when currently live,\na transient endpoint presentation.\n\nThis is read-only with respect to membership, provider execution/lifecycle,\nand durable canvas state. It MUST NOT admit a canvas or create, resume,\nreopen, or restart a provider, including on unavailable or unauthorized\nrequests. Authorization failure MUST NOT expose `source`.\n\nFor an already-live endpoint, authorized resolution MAY issue or refresh\ntransient presentation credentials while constructing the response. Two\nresolutions of the same live incarnation MAY therefore return different\nURLs. Credential refresh alone MUST NOT rerun provider open, change the\nincarnation, or require a canvas state revision. If the live state changes\nconcurrently during resolution, the response MUST report that state's\ncurrent availability, revision, and incarnation; any returned `source`\nMUST correspond to that reported state.\n\nClients MUST NOT replace a newer attachment with a superseded resolution\nresponse, even when credential renewal leaves `revision` and `incarnation`\nunchanged. These state guards do not order same-state credential refreshes.\n\nIf the canvas does not currently have a live endpoint, `source` is absent\nand `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`).\nCall `restartCanvasProvider` (an explicitly effectful operation) to\nattempt recovery instead; calling `resolveCanvasSource` again only\nretries reading the current state without restarting anything.\n\nA client-local page reload needs no provider restart or new effectful\ncommand. Before reload or reattachment, the client SHOULD resolve a fresh\npresentation unless the existing credential is known to remain valid and\nreusable. An absent expiry hint does not imply indefinite validity or\nreusability. Presentation URLs and credentials MUST NOT enter durable\nmembership, editor restoration data, or routine logs.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI (an already-opened canvas's `resource`)." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, + "ResolveCanvasSourceResult": { + "type": "object", + "description": "The canvas's current live-resolution state as of this read.", + "properties": { + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current {@link CanvasEntry.availability}." + }, + "incarnation": { + "type": "string", + "description": "Current {@link CanvasIdentity.incarnation}." + }, + "revision": { + "type": "number", + "description": "Current {@link CanvasEntry.revision}." + }, + "source": { + "$ref": "#/$defs/CanvasSourcePresentation", + "description": "Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}." + } + }, + "required": [ + "availability", + "incarnation", + "revision" + ] + }, + "InvokeCanvasActionParams": { + "type": "object", + "description": "Invokes one of a canvas's currently declared actions exactly once.\n\nThe server MUST reject with `PermissionDenied` (`-32009`) if the canvas's\ncurrent trust is not `trusted`, and with `NotFound` (`-32008`) if\n`actionId` does not match a currently declared action. `incarnation` is\nREQUIRED — omitting stale-generation protection on an effectful call is\nnot allowed. If it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict`\n(`-32011`) rather than route the call to a superseded endpoint.\n\nThe result is the provider's raw reply and is never persisted into\n`CanvasState` — large or provider-specific payloads stay off the durable\nstate tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be\nrepresented out of band instead of being returned inline. Any resulting\nstate changes (e.g. a subsequent availability transition) flow back\nseparately through the normal `canvas/*` action stream on the canvas's\nown channel.\n\nA lost reply (e.g. a dropped connection after the provider already ran\nthe handler) is **indeterminate**: clients MUST NOT automatically replay\n`invokeCanvasAction` on reconnect. Instead, reconnect and read the\ncanvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and\ndecide from observed `revision`/`incarnation` and any provider-visible\nside effect whether to surface the ambiguity to the user, rather than\nassuming success or failure.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "actionId": { + "type": "string", + "description": "Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions." + }, + "input": { + "description": "Input conforming to the declared action's `inputSchema`/`inputSchemaRef`,\nif any. The JSON-serialized value MUST NOT exceed\n`CANVAS_INPUT_MAX_LENGTH`." + }, + "incarnation": { + "type": "string", + "description": "Expected {@link CanvasIdentity.incarnation}. Required — see above. The\nserver MUST reject the call with `Conflict` if the canvas's live\nendpoint has since been superseded, rather than deliver the call to it." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key bounding retry\ndeduplication for this invocation within a live window. The server is\nnot required to guarantee exactly-once execution across a crash. MUST\nNOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." + } + }, + "required": [ + "channel", + "actionId", + "incarnation", + "requestId" + ] + }, + "InvokeCanvasActionResult": { + "type": "object", + "description": "Result of invoking a declared canvas action.", + "properties": { + "result": { + "description": "The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized." + } + }, + "required": [ + "result" + ] + }, + "RestartCanvasProviderParams": { + "type": "object", + "description": "Explicitly restarts the provider/chat-scoped runtime backing this canvas:\nretires the current live endpoint and establishes a fresh one for the\nsame logical instance.\n\nThis is the **only** operation that intentionally causes an\n{@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource`\n(read-only source resolution / client-local page reload) MUST NEVER\ntrigger it. The host dispatches {@link CanvasAvailabilityChangedAction}\n(transitioning through `notLoaded`/`loading`) and then\n{@link CanvasIncarnationChangedAction} to reflect the outcome. Restart\nnever replays a prior `invokeCanvasAction`, and MUST NOT steal focus or\nrestore any prior in-flight effect.\n\n`incarnation` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, so a caller cannot restart a\ngeneration it never observed (e.g. after racing a concurrent restart). A\nlost reply is indeterminate; clients MUST NOT automatically replay this\ncommand — reconnect and compare the canvas's current `incarnation`\ninstead.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." + }, + "incarnation": { + "type": "string", + "description": "Expected current {@link CanvasIdentity.incarnation}; required — see above." + } + }, + "required": [ + "channel", + "requestId", + "incarnation" + ] + }, + "CloseCanvasParams": { + "type": "object", + "description": "Logically closes a canvas: removes its durable membership from\n`SessionState.canvases` and disposes matching views.\n\nThis is distinct from a client merely hiding a local tab or view, which is\npresentation-only and MUST NOT dispatch this command. There is no\nadvertised model tool for this operation — it is invoked only by\nUI/RPC callers.\n\n`revision` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasEntry.revision}, so a caller cannot close membership state it\nnever actually observed. If no matching entry exists (e.g. already\nclosed), the server MUST treat this as a successful no-op rather than an\nerror — the `revision` precondition only applies when an entry still\nexists. A lost reply is indeterminate; clients MUST NOT automatically\nreplay this command — reconnect and check `SessionState.canvases`\ninstead.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." + }, + "revision": { + "type": "number", + "description": "Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above." + } + }, + "required": [ + "channel", + "requestId", + "revision" + ] + }, "Icon": { "type": "object", "description": "An optionally-sized icon that can be displayed in a user interface.", @@ -2084,6 +2351,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -2473,6 +2743,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted via `openCanvas` or host publication\nof a correlated, already-open native instance under that command's\nadmission rules. Membership is never implied by discovery, subscription,\nsource resolution, a chat's existence, or a client's earlier focus.\nEach entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the\nexact backing chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -7228,79 +7505,535 @@ "sessions" ] }, - "ActionOrigin": { + "CanvasExtensionSource": { "type": "object", - "description": "Identifies the client that originally dispatched an action.", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", "properties": { - "clientId": { - "type": "string" + "kind": { + "const": "extension" }, - "clientSeq": { - "type": "number" + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." } }, "required": [ - "clientId", - "clientSeq" + "kind", + "extensionId" ] }, - "ActionEnvelope": { + "CanvasPackageSource": { "type": "object", - "description": "Every action is wrapped in an `ActionEnvelope`.\n\nThe envelope identifies the channel the action belongs to (e.g.\n`ahp-root://` for root actions, the session URI for session actions, the\nterminal URI for terminal actions). Individual action payloads carry only\nfields that are intrinsic to the action; the channel comes from the\nenvelope so that any subscribable resource can route its actions uniformly.", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", "properties": { - "channel": { - "$ref": "#/$defs/URI", - "description": "Channel URI this action belongs to." - }, - "action": { - "$ref": "#/$defs/StateAction" + "kind": { + "const": "package" }, - "serverSeq": { - "type": "number" + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "origin": { - "$ref": "#/$defs/ActionOrigin" + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." }, - "rejectionReason": { - "type": "string" + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." } }, "required": [ - "channel", - "action", - "serverSeq" + "kind", + "sourceId", + "packageName" ] }, - "RootAgentsChangedAction": { + "CanvasIdentityKey": { "type": "object", - "description": "Fired when available agent backends or their models change.", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.\n\nThis logical tuple does not widen the owning runtime's native instance-ID\nnamespace. A runtime may require session-wide native IDs across providers;\nhosts MUST preserve that constraint rather than hide native collisions\nwith an invented provider namespace.", "properties": { - "type": { - "const": "root/agentsChanged" + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "agents": { - "type": "array", - "items": { - "$ref": "#/$defs/AgentInfo" - }, - "description": "Updated agent list" + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." } }, "required": [ - "type", - "agents" + "chat", + "source", + "canvasType", + "instanceId" ] }, - "RootActiveSessionsChangedAction": { + "CanvasIdentity": { "type": "object", - "description": "Fired when the number of active sessions changes.", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", "properties": { - "type": { - "const": "root/activeSessionsChanged" + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "activeSessions": { - "type": "number", - "description": "Current count of active sessions" + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nor transient presentation credential renewal for the same still-live\nendpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) MUST NOT execute or start a provider,\nor open, materialize, or admit a canvas. Membership requires `openCanvas`\nor host publication of a correlated, already-open native instance under\nthat command's admission rules.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient: MUST\nNOT be persisted (including durable canvas/session state or editor\nrestoration data), written to routine logs, or treated as a stable\nidentity. A host MAY embed\nshort-lived, single-use credentials in it; such credentials are never\ndurable authority. Renewed credentials MAY produce a different URL for\nthe same incarnation and revision. Reuse is safe only while the\ncredential is known to remain valid and reusable." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), when\nknown. Omission does not imply indefinite validity or reusability, and\nan unexpired credential may still be single-use." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nMembership is admitted by `openCanvas` or by host publication of a\ncorrelated, already-open native instance under that command's admission\nrules, never by discovery, subscription, or source resolution.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison.\nTransient presentation credential renewal alone does not require a\nrevision change." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "ActionOrigin": { + "type": "object", + "description": "Identifies the client that originally dispatched an action.", + "properties": { + "clientId": { + "type": "string" + }, + "clientSeq": { + "type": "number" + } + }, + "required": [ + "clientId", + "clientSeq" + ] + }, + "ActionEnvelope": { + "type": "object", + "description": "Every action is wrapped in an `ActionEnvelope`.\n\nThe envelope identifies the channel the action belongs to (e.g.\n`ahp-root://` for root actions, the session URI for session actions, the\nterminal URI for terminal actions). Individual action payloads carry only\nfields that are intrinsic to the action; the channel comes from the\nenvelope so that any subscribable resource can route its actions uniformly.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Channel URI this action belongs to." + }, + "action": { + "$ref": "#/$defs/StateAction" + }, + "serverSeq": { + "type": "number" + }, + "origin": { + "$ref": "#/$defs/ActionOrigin" + }, + "rejectionReason": { + "type": "string" + } + }, + "required": [ + "channel", + "action", + "serverSeq" + ] + }, + "RootAgentsChangedAction": { + "type": "object", + "description": "Fired when available agent backends or their models change.", + "properties": { + "type": { + "const": "root/agentsChanged" + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/$defs/AgentInfo" + }, + "description": "Updated agent list" + } + }, + "required": [ + "type", + "agents" + ] + }, + "RootActiveSessionsChangedAction": { + "type": "object", + "description": "Fired when the number of active sessions changes.", + "properties": { + "type": { + "const": "root/activeSessionsChanged" + }, + "activeSessions": { + "type": "number", + "description": "Current count of active sessions" } }, "required": [ @@ -7574,6 +8307,40 @@ "type" ] }, + "SessionCanvasSetAction": { + "type": "object", + "description": "A canvas was admitted (opened) or its catalog entry changed.\n\nUpsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the\nserver dispatches this with the full entry to record a newly opened\ncanvas, or to republish it after a trust/availability/incarnation change\nso subscribers following only the session channel stay in sync with\n{@link CanvasState}. Never client-dispatchable: admission is through\n`openCanvas` or host publication of a correlated, already-open native\ninstance under that command's admission rules. Both paths MUST use the\nsame singular identity-to-resource binding; repeated native observations\nMUST NOT create a second entry. A stale/out-of-order delivery\n(`canvas.revision` not strictly greater than the currently-recorded entry's\nrevision) MUST be rejected (no-op) rather than overwrite a newer entry with\nolder data.", + "properties": { + "type": { + "const": "session/canvasSet" + }, + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The canvas entry to add or update, matched by `resource`." + } + }, + "required": [ + "type", + "canvas" + ] + }, + "SessionCanvasRemovedAction": { + "type": "object", + "description": "A canvas was logically closed.\n\nRemove semantics keyed by `resource`: an unknown URI is a no-op. This\nrepresents durable membership removal, not a client hiding a local\ntab/view — see `closeCanvas`.", + "properties": { + "type": { + "const": "session/canvasRemoved" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}." + } + }, + "required": [ + "type", + "resource" + ] + }, "SessionServerToolsChangedAction": { "type": "object", "description": "Server tools for this session have changed.\n\nFull-replacement semantics: the `tools` array replaces the previous `serverTools` entirely.", @@ -9539,6 +10306,123 @@ "type" ] }, + "CanvasAvailabilityChangedAction": { + "type": "object", + "description": "Replaces the canvas's live resolution state.\n\nDispatched by the host on every availability transition, including\ninitial resolution after admission by `openCanvas` or a correlated native\nopen, provider restart, and endpoint failure/recovery. A client-local page\nreload or transient presentation credential renewal alone does not require\nthis action or a revision change.", + "properties": { + "type": { + "const": "canvas/availabilityChanged" + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "New {@link CanvasState.availability}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in. The reducer\nMUST reject (no-op) this action if `revision` is not strictly greater\nthan the canvas's current `revision` — this is how stale/out-of-order\ndeliveries are consistently rejected across every canvas action, not\njust this one." + } + }, + "required": [ + "type", + "availability", + "revision" + ] + }, + "CanvasTrustChangedAction": { + "type": "object", + "description": "Replaces the canvas's trust decision.\n\nDispatched by the host whenever the execution-trust decision for this\ncanvas's declared actions changes (e.g. a pending decision resolves, or an\nadministrator revokes a previously trusted source).", + "properties": { + "type": { + "const": "canvas/trustChanged" + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "New {@link CanvasState.trust}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "trust", + "revision" + ] + }, + "CanvasIncarnationChangedAction": { + "type": "object", + "description": "Records that the canvas's live endpoint was replaced by a fresh one for\nthe same logical instance (e.g. the owning provider restarted).\n\nRenewing transient presentation credentials for the same live endpoint is\nnot endpoint replacement and MUST NOT trigger this action.\n\nThe host MUST dispatch {@link CanvasAvailabilityChangedAction} to\ntransition through `notLoaded`/`loading` around this change. Receivers\nMUST reject in-flight `invokeCanvasAction` replies and stale server-pushed\ncallbacks addressed to a superseded `incarnation` — because `incarnation`\nis opaque (see {@link CanvasIdentity.incarnation}), that rejection is\ndriven by the accompanying `revision` bump here, not by comparing\n`incarnation` values for order.", + "properties": { + "type": { + "const": "canvas/incarnationChanged" + }, + "incarnation": { + "type": "string", + "description": "New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "incarnation", + "revision" + ] + }, + "CanvasTitleChangedAction": { + "type": "object", + "description": "Replaces the canvas's display title.", + "properties": { + "type": { + "const": "canvas/titleChanged" + }, + "title": { + "type": "string", + "description": "New {@link CanvasState.title}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "title", + "revision" + ] + }, + "CanvasIconChangedAction": { + "type": "object", + "description": "Replaces or removes the canvas's display icon.\n\nThis is presentation metadata only. It does not replace the live endpoint,\nchange the canvas incarnation, or replay any canvas effect.", + "properties": { + "type": { + "const": "canvas/iconChanged" + }, + "icon": { + "oneOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "type": "null" + } + ], + "description": "New {@link CanvasState.icon}; `null` removes the current icon." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "icon", + "revision" + ] + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -9641,6 +10525,12 @@ { "$ref": "#/$defs/SessionMetaChangedAction" }, + { + "$ref": "#/$defs/SessionCanvasSetAction" + }, + { + "$ref": "#/$defs/SessionCanvasRemovedAction" + }, { "$ref": "#/$defs/ChatTurnStartedAction" }, @@ -9832,6 +10722,21 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/CanvasAvailabilityChangedAction" + }, + { + "$ref": "#/$defs/CanvasTrustChangedAction" + }, + { + "$ref": "#/$defs/CanvasIncarnationChangedAction" + }, + { + "$ref": "#/$defs/CanvasTitleChangedAction" + }, + { + "$ref": "#/$defs/CanvasIconChangedAction" } ], "description": "Discriminated union of all state actions." @@ -9983,6 +10888,18 @@ ], "description": "A string that may optionally be rendered as Markdown.\n\n- A plain `string` is rendered as-is (no Markdown processing).\n- An object with `{ markdown: string }` is rendered with Markdown formatting." }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." + }, "JsonPrimitive": { "oneOf": [ { @@ -10642,6 +11559,54 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "PendingMessageKind": { "enum": [ "steering", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 71cb746b..3cd11a68 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -513,6 +513,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -902,6 +905,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted via `openCanvas` or host publication\nof a correlated, already-open native instance under that command's\nadmission rules. Membership is never implied by discovery, subscription,\nsource resolution, a chat's existence, or a client's earlier focus.\nEach entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the\nexact backing chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -5657,286 +5667,756 @@ "sessions" ] }, - "BaseParams": { + "CanvasExtensionSource": { "type": "object", - "description": "Base shape every command's params extends.\n\n`channel` identifies the channel the command targets, mirroring the\n`channel` field on every protocol notification. For commands that operate\non a specific channel (a session, terminal, or changeset), `channel` is\nthat channel's URI. For commands that are connection-level rather than\nchannel-scoped (e.g. {@link InitializeParams | `initialize`},\n{@link PingParams | `ping`}, {@link ListSessionsParams | `listSessions`},\nthe `resource*` filesystem commands, and {@link AuthenticateParams |\n`authenticate`}), the params type narrows `channel` to the literal\nroot URI `'ahp-root://'`.\n\nThis invariant lets implementations route every incoming message —\nrequest, response, or notification — by inspecting `params.channel`\nwithout needing to know the per-method param shape.", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", "properties": { - "channel": { - "$ref": "#/$defs/URI", - "description": "Channel URI this command targets." + "kind": { + "const": "extension" }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." } }, "required": [ - "channel" + "kind", + "extensionId" ] }, - "PaginatedParams": { + "CanvasPackageSource": { "type": "object", - "description": "Cursor-based pagination inputs, mixed into the params of any list command\nthat can page a large result set (e.g. {@link ListSessionsParams |\n`listSessions`}). The paired output is {@link PaginatedResult}.\n\nPagination is **opaque and cursor-based**, mirroring the shape `fetchTurns`\nalready uses for chat history: the server owns the ordering and keyset, and\nthe client walks pages by echoing the cursor from the previous\n{@link PaginatedResult.nextCursor} back on the next request.\n\nThe contract every paginated command shares:\n\n- To fetch the first page, omit `cursor`. Supply `limit` to bound the page.\n- If the result carries a {@link PaginatedResult.nextCursor}, more entries\n exist — pass it back as `cursor` to fetch the following page. A missing\n `nextCursor` signals the end of the collection.\n- Cursors are **server-defined and opaque**: clients MUST NOT parse, modify,\n or persist them across connections. An unrecognised cursor SHOULD be\n rejected with an `InvalidParams` error.\n- Pagination is **fully additive**: a client that omits `limit`/`cursor` and\n ignores `nextCursor` sees the pre-pagination behaviour (subject to any\n server-imposed cap), and a server that does not paginate ignores the inputs\n and returns everything in a single page.", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", "properties": { - "limit": { - "type": "number", - "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + "kind": { + "const": "package" }, - "cursor": { - "type": "string", - "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." - } - } - }, - "PaginatedResult": { - "type": "object", - "description": "Cursor-based pagination output, extended by the result of any list command\nthat can page a large result set (e.g. {@link ListSessionsResult |\n`listSessions`}). See {@link PaginatedParams} for the full pagination\ncontract shared by every paginated command.", - "properties": { - "nextCursor": { - "type": "string", - "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." - } - } - }, - "Implementation": { - "type": "object", - "description": "Identifies a protocol implementation — the software (and build) on one end\nof the connection, as distinct from the {@link AgentInfo | agent persona} it\nhosts. Carried as {@link InitializeParams.clientInfo | `clientInfo`} on the\nclient side and {@link InitializeResult.serverInfo | `serverInfo`} on the\nserver side, mirroring LSP's `clientInfo`/`serverInfo` and MCP's\n`Implementation`.\n\nThis is **informational only**: it exists for logging, telemetry, an\nabout/status affordance, and — as a last resort — a known-issue workaround\nfor a specific buggy build. It is **not** a feature-detection mechanism.\nFeature availability stays with the capability model\n({@link ClientCapabilities} and the various `*.capabilities` declarations);\nimplementations SHOULD NOT gate protocol behaviour on parsing\n{@link Implementation.version | `version`}.", - "properties": { - "name": { + "sourceId": { "type": "string", - "description": "Implementation name, e.g. a product or package identifier." + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "version": { + "packageName": { "type": "string", - "description": "Implementation version. A [SemVer](https://semver.org) string is\nrecommended but not required." + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." }, - "title": { + "version": { "type": "string", - "description": "Optional human-readable display name." + "description": "Installed package version, when known. Metadata only — not identity-bearing." } }, "required": [ - "name" + "kind", + "sourceId", + "packageName" ] }, - "InitializeParams": { + "CanvasIdentityKey": { "type": "object", - "description": "Establishes a new connection and negotiates the protocol version.\nThis MUST be the first message sent by the client.", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.\n\nThis logical tuple does not widen the owning runtime's native instance-ID\nnamespace. A runtime may require session-wide native IDs across providers;\nhosts MUST preserve that constraint rather than hide native collisions\nwith an invented provider namespace.", "properties": { - "channel": { - "type": "string", - "enum": [ - "ahp-root://" - ] - }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "protocolVersions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects one entry and returns it as `InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." }, - "clientId": { + "canvasType": { "type": "string", - "description": "Unique client identifier" + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "clientInfo": { - "$ref": "#/$defs/Implementation", - "description": "Optional identity of the client implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Distinct from {@link InitializeParams.clientId | `clientId`},\nwhich is an opaque per-connection identifier used for reconnection, not a\nhuman-readable implementation name." - }, - "initialSubscriptions": { - "type": "array", - "items": { - "$ref": "#/$defs/URI" - }, - "description": "URIs to subscribe to during handshake" - }, - "locale": { + "instanceId": { "type": "string", - "description": "IETF BCP 47 language tag indicating the client's preferred locale\n(e.g. `\"en-US\"`, `\"ja\"`). The server SHOULD use this to localise\nuser-facing strings such as confirmation option labels." - }, - "capabilities": { - "$ref": "#/$defs/ClientCapabilities", - "description": "Optional client capability declarations.\n\nServers SHOULD only advertise features whose corresponding client\ncapability is set here. Absent means \"not declared\" — the server\nMUST assume the client does not support the feature." + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." } }, "required": [ - "channel", - "protocolVersions", - "clientId" + "chat", + "source", + "canvasType", + "instanceId" ] }, - "ClientCapabilities": { - "type": "object", - "description": "Optional capabilities a client declares during `initialize`.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities\nare reserved for future per-capability options.", - "properties": { - "mcpApps": { - "type": "object", - "additionalProperties": {}, - "description": "Client can render\n[MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e.\nit can host the View sandbox, run the `ui/*` protocol against it,\nand forward `mcp://`-channel traffic on the App's behalf.\n\nHosts SHOULD only populate\n{@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`}\n(and expose the corresponding\n{@link McpServerCustomization.channel | `mcp://` channel}) when this\ncapability is declared. Clients that omit it MUST treat\nApp-bearing tool calls as ordinary MCP tool calls." - } - } - }, - "InitializeResult": { + "CanvasIdentity": { "type": "object", - "description": "Result of the `initialize` command.\n\n`protocolVersion` is the version the server has selected from the client's\n`protocolVersions` list. The client and server MUST use this version for\nthe rest of the connection. If the server cannot speak any of the offered\nversions it MUST return error code `-32005` (`UnsupportedProtocolVersion`)\nwith required `UnsupportedProtocolVersionErrorData` containing\n`supportedVersions`, instead of a result.", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", "properties": { - "protocolVersion": { - "type": "string", - "description": "Protocol version selected by the server. MUST be one of the entries in\n`InitializeParams.protocolVersions`. Formatted as a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`)." - }, - "serverSeq": { - "type": "number", - "description": "Current server sequence number" - }, - "serverInfo": { - "$ref": "#/$defs/Implementation", - "description": "Optional identity of the server implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Whereas {@link InitializeResult.protocolVersion | `protocolVersion`}\nidentifies the negotiated protocol, `serverInfo` identifies the host\nsoftware behind it." - }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional implementation-specific extension metadata advertised by the host.\n\nHosts and clients MAY agree on namespaced keys for capabilities that are not\npart of the standardized protocol. Clients MUST ignore keys they do not\nunderstand. Capabilities needed for interoperable behavior SHOULD use typed\nfields on {@link InitializeResult} instead." - }, - "snapshots": { - "type": "array", - "items": { - "$ref": "#/$defs/Snapshot" - }, - "description": "Snapshots for each `initialSubscriptions` URI" - }, - "defaultDirectory": { + "chat": { "$ref": "#/$defs/URI", - "description": "Suggested default directory for remote filesystem browsing" + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." }, - "completionTriggerCharacters": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Characters that, when typed in a {@link Message} input, SHOULD cause\nthe client to issue a `completions` request with\n{@link CompletionItemKind.UserMessage}. Typically includes characters like\n`'@'` or `'/'`." + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." }, - "terminalCommandPrefix": { + "canvasType": { "type": "string", - "description": "Prefix that the host recognizes at the start of a user {@link Message.text}\nas a shorthand for executing the remainder as a terminal command. Currently\nthe standardized convention is `\"!\"`; absence means the host does not\nsupport command prefixes." + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "telemetry": { - "$ref": "#/$defs/TelemetryCapabilities", - "description": "OTLP telemetry channels the host emits, if any. Each populated field is\neither a literal `ahp-otlp:` channel URI or an RFC 6570 URI template a\nclient expands before subscribing (currently only the `logs` channel\ndefines a template variable, `{level}`, for subscriber-side severity\nfiltering). Clients MAY ignore signals they cannot process." + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." }, - "automations": { - "$ref": "#/$defs/AutomationCapabilities", - "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nor transient presentation credential renewal for the same still-live\nendpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." } }, "required": [ - "protocolVersion", - "serverSeq", - "snapshots" + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" ] }, - "AutomationCapabilities": { + "CanvasTrustedState": { "type": "object", - "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", "properties": { - "create": { - "$ref": "#/$defs/AutomationCreateCapability", - "description": "Present when clients may dispatch {@link AutomationCreateRequestedAction}." - }, - "schedules": { - "$ref": "#/$defs/AutomationScheduleCapabilities", - "description": "Present when definitions may contain {@link AutomationScheduleTrigger | schedule triggers}." - }, - "runCancellation": { - "$ref": "#/$defs/AutomationRunCancellationCapability", - "description": "Present when clients may request cancellation of `pending` or `running`\nautomation runs." - }, - "runHistoryLimit": { - "type": "number", - "description": "Maximum terminal entries retained in {@link AutomationEntry.runs}. Active\nruns are not counted toward the limit. Absence means the retention limit is\nimplementation-defined." + "status": { + "const": "trusted" } - } - }, - "AutomationCreateCapability": { - "type": "object", - "description": "Presence capability for {@link AutomationCreateRequestedAction |\n`automation/createRequested`}.\n\nThe empty object means \"supported\"; fields are reserved for future\ncreate-specific options.", - "properties": {} + }, + "required": [ + "status" + ] }, - "AutomationScheduleCapabilities": { + "CanvasPendingTrustState": { "type": "object", - "description": "Host restrictions on portable {@link AutomationSchedule} triggers.\n\nThe cron grammar itself is fixed by AHP. Hosts MUST accept every expression\nin that grammar unless it violates an advertised interval restriction.", "properties": { - "minIntervalMinutes": { - "type": "number", - "description": "Smallest permitted interval between consecutive occurrences produced by\n{@link AutomationSchedule.expression}. Omission means no restriction beyond\nthe cron format's one-minute resolution." + "status": { + "const": "pending" } - } - }, - "AutomationRunCancellationCapability": { - "type": "object", - "description": "Presence capability for {@link AutomationRunCancelRequestedAction |\n`automationRun/cancelRequested`}.\n\nThe empty object means \"supported.\" Clients may dispatch the action for\n`pending` or `running` runs; terminal runs cannot be cancelled.", - "properties": {} + }, + "required": [ + "status" + ] }, - "PingParams": { + "CanvasBlockedTrustState": { "type": "object", - "description": "Verifies that the AHP connection is still alive and keeps it from being\nclosed by idle-timeout intermediaries (proxies, load balancers, etc.).\n\nThe server MUST respond regardless of whether the client has completed\n`initialize` or holds any subscriptions. Ping carries no payload in either\ndirection; the response itself is the signal.", "properties": { - "channel": { - "type": "string", - "enum": [ - "ahp-root://" - ] + "status": { + "const": "blocked" }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." } }, "required": [ - "channel" + "status" ] }, - "ReconnectParams": { + "CanvasActionDeclaration": { "type": "object", - "description": "Re-establishes a dropped connection. The server replays missed actions or\nprovides fresh snapshots.", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", "properties": { - "channel": { + "id": { "type": "string", - "enum": [ - "ahp-root://" - ] + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." }, - "_meta": { - "type": "object", - "additionalProperties": {}, - "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." - }, - "clientId": { + "title": { "type": "string", - "description": "Client identifier from the original connection" + "description": "Human-readable display name." }, - "lastSeenServerSeq": { - "type": "number", - "description": "Last `serverSeq` the client received" + "description": { + "type": "string", + "description": "Description of what invoking the action does." }, - "subscriptions": { - "type": "array", - "items": { - "$ref": "#/$defs/URI" - }, - "description": "URIs the client was subscribed to" - } - }, - "required": [ - "channel", - "clientId", + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) MUST NOT execute or start a provider,\nor open, materialize, or admit a canvas. Membership requires `openCanvas`\nor host publication of a correlated, already-open native instance under\nthat command's admission rules.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient: MUST\nNOT be persisted (including durable canvas/session state or editor\nrestoration data), written to routine logs, or treated as a stable\nidentity. A host MAY embed\nshort-lived, single-use credentials in it; such credentials are never\ndurable authority. Renewed credentials MAY produce a different URL for\nthe same incarnation and revision. Reuse is safe only while the\ncredential is known to remain valid and reusable." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), when\nknown. Omission does not imply indefinite validity or reusability, and\nan unexpired credential may still be single-use." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nMembership is admitted by `openCanvas` or by host publication of a\ncorrelated, already-open native instance under that command's admission\nrules, never by discovery, subscription, or source resolution.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison.\nTransient presentation credential renewal alone does not require a\nrevision change." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "BaseParams": { + "type": "object", + "description": "Base shape every command's params extends.\n\n`channel` identifies the channel the command targets, mirroring the\n`channel` field on every protocol notification. For commands that operate\non a specific channel (a session, terminal, or changeset), `channel` is\nthat channel's URI. For commands that are connection-level rather than\nchannel-scoped (e.g. {@link InitializeParams | `initialize`},\n{@link PingParams | `ping`}, {@link ListSessionsParams | `listSessions`},\nthe `resource*` filesystem commands, and {@link AuthenticateParams |\n`authenticate`}), the params type narrows `channel` to the literal\nroot URI `'ahp-root://'`.\n\nThis invariant lets implementations route every incoming message —\nrequest, response, or notification — by inspecting `params.channel`\nwithout needing to know the per-method param shape.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Channel URI this command targets." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, + "PaginatedParams": { + "type": "object", + "description": "Cursor-based pagination inputs, mixed into the params of any list command\nthat can page a large result set (e.g. {@link ListSessionsParams |\n`listSessions`}). The paired output is {@link PaginatedResult}.\n\nPagination is **opaque and cursor-based**, mirroring the shape `fetchTurns`\nalready uses for chat history: the server owns the ordering and keyset, and\nthe client walks pages by echoing the cursor from the previous\n{@link PaginatedResult.nextCursor} back on the next request.\n\nThe contract every paginated command shares:\n\n- To fetch the first page, omit `cursor`. Supply `limit` to bound the page.\n- If the result carries a {@link PaginatedResult.nextCursor}, more entries\n exist — pass it back as `cursor` to fetch the following page. A missing\n `nextCursor` signals the end of the collection.\n- Cursors are **server-defined and opaque**: clients MUST NOT parse, modify,\n or persist them across connections. An unrecognised cursor SHOULD be\n rejected with an `InvalidParams` error.\n- Pagination is **fully additive**: a client that omits `limit`/`cursor` and\n ignores `nextCursor` sees the pre-pagination behaviour (subject to any\n server-imposed cap), and a server that does not paginate ignores the inputs\n and returns everything in a single page.", + "properties": { + "limit": { + "type": "number", + "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." + } + } + }, + "PaginatedResult": { + "type": "object", + "description": "Cursor-based pagination output, extended by the result of any list command\nthat can page a large result set (e.g. {@link ListSessionsResult |\n`listSessions`}). See {@link PaginatedParams} for the full pagination\ncontract shared by every paginated command.", + "properties": { + "nextCursor": { + "type": "string", + "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." + } + } + }, + "Implementation": { + "type": "object", + "description": "Identifies a protocol implementation — the software (and build) on one end\nof the connection, as distinct from the {@link AgentInfo | agent persona} it\nhosts. Carried as {@link InitializeParams.clientInfo | `clientInfo`} on the\nclient side and {@link InitializeResult.serverInfo | `serverInfo`} on the\nserver side, mirroring LSP's `clientInfo`/`serverInfo` and MCP's\n`Implementation`.\n\nThis is **informational only**: it exists for logging, telemetry, an\nabout/status affordance, and — as a last resort — a known-issue workaround\nfor a specific buggy build. It is **not** a feature-detection mechanism.\nFeature availability stays with the capability model\n({@link ClientCapabilities} and the various `*.capabilities` declarations);\nimplementations SHOULD NOT gate protocol behaviour on parsing\n{@link Implementation.version | `version`}.", + "properties": { + "name": { + "type": "string", + "description": "Implementation name, e.g. a product or package identifier." + }, + "version": { + "type": "string", + "description": "Implementation version. A [SemVer](https://semver.org) string is\nrecommended but not required." + }, + "title": { + "type": "string", + "description": "Optional human-readable display name." + } + }, + "required": [ + "name" + ] + }, + "InitializeParams": { + "type": "object", + "description": "Establishes a new connection and negotiates the protocol version.\nThis MUST be the first message sent by the client.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ] + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "protocolVersions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects one entry and returns it as `InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." + }, + "clientId": { + "type": "string", + "description": "Unique client identifier" + }, + "clientInfo": { + "$ref": "#/$defs/Implementation", + "description": "Optional identity of the client implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Distinct from {@link InitializeParams.clientId | `clientId`},\nwhich is an opaque per-connection identifier used for reconnection, not a\nhuman-readable implementation name." + }, + "initialSubscriptions": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "URIs to subscribe to during handshake" + }, + "locale": { + "type": "string", + "description": "IETF BCP 47 language tag indicating the client's preferred locale\n(e.g. `\"en-US\"`, `\"ja\"`). The server SHOULD use this to localise\nuser-facing strings such as confirmation option labels." + }, + "capabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "Optional client capability declarations.\n\nServers SHOULD only advertise features whose corresponding client\ncapability is set here. Absent means \"not declared\" — the server\nMUST assume the client does not support the feature." + } + }, + "required": [ + "channel", + "protocolVersions", + "clientId" + ] + }, + "ClientCapabilities": { + "type": "object", + "description": "Optional capabilities a client declares during `initialize`.\n\nEach field is a presence flag: an empty object `{}` means \"supported\",\nabsence means \"not supported\". Sub-fields on individual capabilities\nare reserved for future per-capability options.", + "properties": { + "mcpApps": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render\n[MCP Apps](https://github.com/modelcontextprotocol/ext-apps) — i.e.\nit can host the View sandbox, run the `ui/*` protocol against it,\nand forward `mcp://`-channel traffic on the App's behalf.\n\nHosts SHOULD only populate\n{@link McpServerCustomization.mcpApp | `McpServerCustomization.mcpApp`}\n(and expose the corresponding\n{@link McpServerCustomization.channel | `mcp://` channel}) when this\ncapability is declared. Clients that omit it MUST treat\nApp-bearing tool calls as ordinary MCP tool calls." + }, + "canvases": { + "type": "object", + "additionalProperties": {}, + "description": "Client can render local canvases: `listCanvasTypes`, `openCanvas`,\nsubscribe to the resulting `ahp-canvas:` channel, and drive\n`resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` /\n`closeCanvas`.\n\nHosts SHOULD NOT offer canvas admission to a client that omits this\ncapability; such a client MUST be treated as if every canvas were\n{@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply\nanything about server/runtime execution trust — see\n{@link CanvasTrustStatus}, which is a separate, host-owned decision.\n\nThis declares only the CLIENT's rendering capability. Protocol version\nsupport alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER\nactually has a working canvas runtime — see\n{@link InitializeResult.canvases}, the server-side counterpart, which a\nclient MUST also check before treating canvases as usable." + } + } + }, + "InitializeResult": { + "type": "object", + "description": "Result of the `initialize` command.\n\n`protocolVersion` is the version the server has selected from the client's\n`protocolVersions` list. The client and server MUST use this version for\nthe rest of the connection. If the server cannot speak any of the offered\nversions it MUST return error code `-32005` (`UnsupportedProtocolVersion`)\nwith required `UnsupportedProtocolVersionErrorData` containing\n`supportedVersions`, instead of a result.", + "properties": { + "protocolVersion": { + "type": "string", + "description": "Protocol version selected by the server. MUST be one of the entries in\n`InitializeParams.protocolVersions`. Formatted as a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`)." + }, + "serverSeq": { + "type": "number", + "description": "Current server sequence number" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Optional identity of the server implementation (name and version).\nInformational only — see {@link Implementation} for how it may and may not\nbe used. Whereas {@link InitializeResult.protocolVersion | `protocolVersion`}\nidentifies the negotiated protocol, `serverInfo` identifies the host\nsoftware behind it." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional implementation-specific extension metadata advertised by the host.\n\nHosts and clients MAY agree on namespaced keys for capabilities that are not\npart of the standardized protocol. Clients MUST ignore keys they do not\nunderstand. Capabilities needed for interoperable behavior SHOULD use typed\nfields on {@link InitializeResult} instead." + }, + "snapshots": { + "type": "array", + "items": { + "$ref": "#/$defs/Snapshot" + }, + "description": "Snapshots for each `initialSubscriptions` URI" + }, + "defaultDirectory": { + "$ref": "#/$defs/URI", + "description": "Suggested default directory for remote filesystem browsing" + }, + "completionTriggerCharacters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Characters that, when typed in a {@link Message} input, SHOULD cause\nthe client to issue a `completions` request with\n{@link CompletionItemKind.UserMessage}. Typically includes characters like\n`'@'` or `'/'`." + }, + "terminalCommandPrefix": { + "type": "string", + "description": "Prefix that the host recognizes at the start of a user {@link Message.text}\nas a shorthand for executing the remainder as a terminal command. Currently\nthe standardized convention is `\"!\"`; absence means the host does not\nsupport command prefixes." + }, + "telemetry": { + "$ref": "#/$defs/TelemetryCapabilities", + "description": "OTLP telemetry channels the host emits, if any. Each populated field is\neither a literal `ahp-otlp:` channel URI or an RFC 6570 URI template a\nclient expands before subscribing (currently only the `logs` channel\ndefines a template variable, `{level}`, for subscriber-side severity\nfiltering). Clients MAY ignore signals they cannot process." + }, + "automations": { + "$ref": "#/$defs/AutomationCapabilities", + "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + }, + "canvases": { + "$ref": "#/$defs/CanvasCapabilities", + "description": "Host/runtime-owned local-canvas support. Presence means the SERVER\ncurrently has a working runtime able to serve `openCanvas` /\n`invokeCanvasAction` for at least one qualifying (explicitly installed\nand trust-eligible) extension/package source; absence means the host\nhas no available canvas runtime, and clients MUST treat every canvas as\n{@link CanvasAvailabilityStatus.Unsupported} regardless of what\n{@link ClientCapabilities.canvases} declared.\n\n**Protocol version support alone is not a runtime capability**: a host\nspeaking protocol `>= 0.10.0` without this field present MUST NOT be\nassumed to have a usable canvas runtime. This field — not the\nnegotiated `protocolVersion` — is the authoritative signal, and is\nindependent of any individual canvas's live availability\n({@link CanvasAvailabilityState}) or trust decision\n({@link CanvasTrustState})." + } + }, + "required": [ + "protocolVersion", + "serverSeq", + "snapshots" + ] + }, + "CanvasCapabilities": { + "type": "object", + "description": "Local-canvas runtime features supported by this host authority. The empty\nobject means \"supported\" — see {@link InitializeResult.canvases} for what\npresence/absence of this field itself means.", + "properties": {} + }, + "AutomationCapabilities": { + "type": "object", + "description": "Automation features supported by this host authority.\n\nThe presence of this object advertises the baseline `ahp-automations://`\ncatalogue. Optional fields describe additional host features and\nrestrictions.\n\nCapabilities describe implementation support.\n{@link AutomationEntry.operations} remains authoritative for which\ndefinition mutations are currently allowed on a particular automation.", + "properties": { + "create": { + "$ref": "#/$defs/AutomationCreateCapability", + "description": "Present when clients may dispatch {@link AutomationCreateRequestedAction}." + }, + "schedules": { + "$ref": "#/$defs/AutomationScheduleCapabilities", + "description": "Present when definitions may contain {@link AutomationScheduleTrigger | schedule triggers}." + }, + "runCancellation": { + "$ref": "#/$defs/AutomationRunCancellationCapability", + "description": "Present when clients may request cancellation of `pending` or `running`\nautomation runs." + }, + "runHistoryLimit": { + "type": "number", + "description": "Maximum terminal entries retained in {@link AutomationEntry.runs}. Active\nruns are not counted toward the limit. Absence means the retention limit is\nimplementation-defined." + } + } + }, + "AutomationCreateCapability": { + "type": "object", + "description": "Presence capability for {@link AutomationCreateRequestedAction |\n`automation/createRequested`}.\n\nThe empty object means \"supported\"; fields are reserved for future\ncreate-specific options.", + "properties": {} + }, + "AutomationScheduleCapabilities": { + "type": "object", + "description": "Host restrictions on portable {@link AutomationSchedule} triggers.\n\nThe cron grammar itself is fixed by AHP. Hosts MUST accept every expression\nin that grammar unless it violates an advertised interval restriction.", + "properties": { + "minIntervalMinutes": { + "type": "number", + "description": "Smallest permitted interval between consecutive occurrences produced by\n{@link AutomationSchedule.expression}. Omission means no restriction beyond\nthe cron format's one-minute resolution." + } + } + }, + "AutomationRunCancellationCapability": { + "type": "object", + "description": "Presence capability for {@link AutomationRunCancelRequestedAction |\n`automationRun/cancelRequested`}.\n\nThe empty object means \"supported.\" Clients may dispatch the action for\n`pending` or `running` runs; terminal runs cannot be cancelled.", + "properties": {} + }, + "PingParams": { + "type": "object", + "description": "Verifies that the AHP connection is still alive and keeps it from being\nclosed by idle-timeout intermediaries (proxies, load balancers, etc.).\n\nThe server MUST respond regardless of whether the client has completed\n`initialize` or holds any subscriptions. Ping carries no payload in either\ndirection; the response itself is the signal.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ] + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, + "ReconnectParams": { + "type": "object", + "description": "Re-establishes a dropped connection. The server replays missed actions or\nprovides fresh snapshots.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ] + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "clientId": { + "type": "string", + "description": "Client identifier from the original connection" + }, + "lastSeenServerSeq": { + "type": "number", + "description": "Last `serverSeq` the client received" + }, + "subscriptions": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "URIs the client was subscribed to" + } + }, + "required": [ + "channel", + "clientId", "lastSeenServerSeq", "subscriptions" ] @@ -7172,150 +7652,403 @@ "uri" ] }, - "CreateResourceWatchResult": { + "CreateResourceWatchResult": { + "type": "object", + "description": "Result of the `createResourceWatch` command.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Receiver-assigned watch channel URI (`ahp-resource-watch:/`). The\ncaller subscribes to this URI to start receiving change events and\nunsubscribes to release the watcher." + } + }, + "required": [ + "channel" + ] + }, + "ListAutomationTriggerDefinitionsParams": { + "type": "object", + "description": "Discover event-trigger types available for a prospective session template.\n\nHosts may vary definitions by provider, workspace, and session\nconfiguration. Schedule triggers are protocol-defined and therefore do not\nappear in this result. The result describes current authoring and validation\nchoices. Saved {@link AutomationEventTrigger} values retain their selected\nevent descriptors for display but do not establish current availability.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-root://" + ], + "description": "Trigger definitions are discovered from the root channel." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "provider": { + "type": "string", + "description": "Prospective provider id matching {@link AgentInfo.provider}, or omitted for the host default." + }, + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "Prospective {@link AutomationSessionTemplate.workingDirectories}." + }, + "sessionConfig": { + "type": "object", + "additionalProperties": {}, + "description": "Prospective resolved {@link AutomationSessionTemplate.config}." + } + }, + "required": [ + "channel" + ] + }, + "ListAutomationTriggerDefinitionsResult": { + "type": "object", + "description": "Host-defined event trigger types available for the supplied context.", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/AutomationTriggerDefinition" + }, + "description": "Available event trigger definitions." + } + }, + "required": [ + "items" + ] + }, + "RunAutomationParams": { + "type": "object", + "description": "Start a manual run of an automation.\n\nManual execution is independent of {@link AutomationDefinition.enabled}.\nThe host persists the run before beginning session side effects.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-automations://" + ], + "description": "Manual runs are scoped to the catalogue channel." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "automation": { + "$ref": "#/$defs/URI", + "description": "Target {@link AutomationEntry.resource}." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key. Retrying with the same key and\nautomation MUST return the original run URI rather than create another\nrun." + } + }, + "required": [ + "channel", + "automation", + "requestId" + ] + }, + "RunAutomationResult": { + "type": "object", + "description": "Result identifying the existing or newly created run.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-automation-run:` URI matching {@link AutomationRunState.resource}." + } + }, + "required": [ + "resource" + ] + }, + "FetchAutomationRunsParams": { + "type": "object", + "description": "Load one older page into a catalogued automation's run-history state.\n\nThe response only acknowledges the request. The updated full state arrives\nthrough {@link AutomationSetAction | `automation/set`} on the\n`ahp-automations://` channel, keeping all catalogue subscribers synchronized\nthrough the normal action stream.", + "properties": { + "channel": { + "type": "string", + "enum": [ + "ahp-automations://" + ], + "description": "Run-history loading is scoped to the catalogue channel." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "automation": { + "$ref": "#/$defs/URI", + "description": "Target {@link AutomationEntry.resource}." + }, + "cursor": { + "type": "string", + "description": "Cursor previously received as {@link AutomationEntry.runsNextCursor}.\nOmit to request the first page not already included by the snapshot." + } + }, + "required": [ + "channel", + "automation" + ] + }, + "FetchAutomationRunsResult": { + "type": "object", + "description": "Empty acknowledgement; the updated automation state is delivered by action.", + "properties": {} + }, + "ListCanvasTypesParams": { + "type": "object", + "description": "Discovers canvas TYPES currently available to open for one exact backing\nchat.\n\nThis is a **pure read/browse** operation: it MUST NOT execute or start a\nprovider, or open, materialize, or otherwise admit any canvas. See\n`openCanvas` for the admission rules, including publication of an\nalready-open native instance. This catalogue is unrelated to\n{@link SessionState.canvases}, which reflects durable membership of\nalready-opened canvas INSTANCES, not the set of canvas TYPES a\nhost/extension could open; do not confuse the two.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat to discover available canvas types for." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "limit": { + "type": "number", + "description": "Maximum number of entries to return in this page. The server SHOULD respect\nthis bound but MAY return fewer entries and MAY impose its own upper cap.\nOmit to let the server choose the page size." + }, + "cursor": { + "type": "string", + "description": "Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}.\nOmit to fetch the first page. Cursors are server-defined and MUST be treated\nas opaque — do not parse, modify, or persist them across connections. An\nunrecognised cursor SHOULD be rejected with an `InvalidParams` error." + } + }, + "required": [ + "channel" + ] + }, + "ListCanvasTypesResult": { + "type": "object", + "description": "Available canvas types for the requested chat.", + "properties": { + "nextCursor": { + "type": "string", + "description": "Opaque cursor for the next page. Present when more entries exist beyond the\nreturned page; absent signals the end of the collection. Pass it back as\n{@link PaginatedParams.cursor} to fetch the following page." + }, + "types": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasTypeDeclaration" + }, + "description": "Discovered canvas type declarations." + } + }, + "required": [ + "types" + ] + }, + "OpenCanvasParams": { + "type": "object", + "description": "Explicitly opens (admits) a canvas, associating it with the owning chat\ngiven by `identity.chat` at the moment of the call — never with whichever\nchat later happens to have focus.\n\nCanvas membership requires explicit admission. A client admits a canvas\nby calling `openCanvas`, a read-write operation. `listCanvasTypes`,\n`subscribe`, and `resolveCanvasSource` MUST NOT admit a canvas or execute\nor start its provider.\n\nA host MAY also publish membership after observing an instance already\nopened by the owning native runtime. Before publication, the host MUST\ncorrelate the observation to the actual backing chat, canonical source,\ncanvas type, and native instance, and enforce applicable\nexecution-admission policy. Uncorrelated or conflicting observations MUST\nbe rejected rather than assigned to the focused chat or a guessed source.\nObservation does not grant execution trust: it MUST NOT convert `pending`\nor `blocked` trust to `trusted`; trust and availability remain independent.\n\nNative publication follows the same singular identity-to-resource binding\nand authoritative state rules as client-originated admission. The host\nMUST NOT manufacture a client `openCanvas` request or invoke the provider's\nopen handler again merely to publish an already-open instance. Repeated\nobservations MUST NOT duplicate membership; this does not suppress the\nactual effects of a genuinely new native open. Hosts MUST preserve the\nnative instance-ID namespace, including session-wide IDs across providers\nwhere the owning runtime requires them, rather than hide collisions with\nan invented provider namespace. Client `requestId` semantics are unchanged.\n\nOnce admitted by either path, clients read and follow live state by\n`subscribe`-ing to `canvas.resource`, and resolve the current live endpoint\nvia `resolveCanvasSource`; neither read opens, resumes, or restarts anything.\n\n**Logical identity is always singular.** The same {@link CanvasIdentityKey}\n(`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same\n`canvas` resource URI and the same {@link SessionState.canvases} catalog\nentry, no matter how many times `openCanvas` is called or a native open is\nobserved for it. The server MUST reuse that existing entry's `resource`\nrather than mint a second one. A client-supplied `canvas` URI is honored\nonly on the call that first establishes the identity; on a later call for\nan already-recorded identity the server MUST ignore the supplied `canvas`\nvalue and return the existing resource instead.\n\n**Idempotency is scoped to `requestId`, not identity.** Retrying with the\nexact same `requestId` and byte-for-byte identical params from the same\nauthenticated connection MUST return the original result without\nrepeating any side effect, within a bounded live window (the server is\nnot required to remember it forever). Reusing the same `requestId` with\nany different parameter value MUST be rejected with `Conflict`\n(`-32011`) — mint a new `requestId` for a new logical call. A genuinely\nNEW `requestId` for an already-open identity MAY be effectful (e.g.\nupdating `title`/`icon`, or causing the provider to re-run its own\nopen-time initialization with new `input`) — this mirrors the pinned\nSDK's own repeated-open behavior and does not create a second logical\nidentity. There is no exactly-once-across-crash guarantee: a lost reply\nis indeterminate, and clients MUST NOT automatically replay `openCanvas`\n— reconnect and read `SessionState.canvases` / `resolveCanvasSource`\ninstead to determine the actual outcome.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Session URI that will list the opened canvas in `SessionState.canvases`." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "canvas": { + "$ref": "#/$defs/URI", + "description": "Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentityKey", + "description": "Logical identity to open or re-admit." + }, + "title": { + "type": "string", + "description": "Initial (or updated, on a later effectful call) display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Initial (or updated) display icon." + }, + "input": { + "description": "Bounded JSON input for this open call (e.g. seed parameters the\nprovider uses to initialize the canvas), opaque to the protocol. See\n{@link CanvasTypeDeclaration.openInputSchema} /\n`openInputSchemaRef` for the expected shape. The JSON-serialized value\nMUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key bounding retry deduplication\nfor this call within a live window; see the idempotency rules above.\nMUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." + } + }, + "required": [ + "channel", + "canvas", + "identity", + "title", + "requestId" + ] + }, + "OpenCanvasResult": { "type": "object", - "description": "Result of the `createResourceWatch` command.", + "description": "Result identifying the existing or newly opened canvas.", "properties": { - "channel": { - "$ref": "#/$defs/URI", - "description": "Receiver-assigned watch channel URI (`ahp-resource-watch:/`). The\ncaller subscribes to this URI to start receiving change events and\nunsubscribes to release the watcher." + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The catalog entry for the opened (or already-open) canvas." } }, "required": [ - "channel" + "canvas" ] }, - "ListAutomationTriggerDefinitionsParams": { + "ResolveCanvasSourceParams": { "type": "object", - "description": "Discover event-trigger types available for a prospective session template.\n\nHosts may vary definitions by provider, workspace, and session\nconfiguration. Schedule triggers are protocol-defined and therefore do not\nappear in this result. The result describes current authoring and validation\nchoices. Saved {@link AutomationEventTrigger} values retain their selected\nevent descriptors for display but do not establish current availability.", + "description": "Reads a canvas's current live-resolution state and, when currently live,\na transient endpoint presentation.\n\nThis is read-only with respect to membership, provider execution/lifecycle,\nand durable canvas state. It MUST NOT admit a canvas or create, resume,\nreopen, or restart a provider, including on unavailable or unauthorized\nrequests. Authorization failure MUST NOT expose `source`.\n\nFor an already-live endpoint, authorized resolution MAY issue or refresh\ntransient presentation credentials while constructing the response. Two\nresolutions of the same live incarnation MAY therefore return different\nURLs. Credential refresh alone MUST NOT rerun provider open, change the\nincarnation, or require a canvas state revision. If the live state changes\nconcurrently during resolution, the response MUST report that state's\ncurrent availability, revision, and incarnation; any returned `source`\nMUST correspond to that reported state.\n\nClients MUST NOT replace a newer attachment with a superseded resolution\nresponse, even when credential renewal leaves `revision` and `incarnation`\nunchanged. These state guards do not order same-state credential refreshes.\n\nIf the canvas does not currently have a live endpoint, `source` is absent\nand `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`).\nCall `restartCanvasProvider` (an explicitly effectful operation) to\nattempt recovery instead; calling `resolveCanvasSource` again only\nretries reading the current state without restarting anything.\n\nA client-local page reload needs no provider restart or new effectful\ncommand. Before reload or reattachment, the client SHOULD resolve a fresh\npresentation unless the existing credential is known to remain valid and\nreusable. An absent expiry hint does not imply indefinite validity or\nreusability. Presentation URLs and credentials MUST NOT enter durable\nmembership, editor restoration data, or routine logs.", "properties": { "channel": { - "type": "string", - "enum": [ - "ahp-root://" - ], - "description": "Trigger definitions are discovered from the root channel." + "$ref": "#/$defs/URI", + "description": "The canvas URI (an already-opened canvas's `resource`)." }, "_meta": { "type": "object", "additionalProperties": {}, "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." - }, - "provider": { - "type": "string", - "description": "Prospective provider id matching {@link AgentInfo.provider}, or omitted for the host default." - }, - "workingDirectories": { - "type": "array", - "items": { - "$ref": "#/$defs/URI" - }, - "description": "Prospective {@link AutomationSessionTemplate.workingDirectories}." - }, - "sessionConfig": { - "type": "object", - "additionalProperties": {}, - "description": "Prospective resolved {@link AutomationSessionTemplate.config}." } }, "required": [ "channel" ] }, - "ListAutomationTriggerDefinitionsResult": { + "ResolveCanvasSourceResult": { "type": "object", - "description": "Host-defined event trigger types available for the supplied context.", + "description": "The canvas's current live-resolution state as of this read.", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/AutomationTriggerDefinition" - }, - "description": "Available event trigger definitions." + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current {@link CanvasEntry.availability}." + }, + "incarnation": { + "type": "string", + "description": "Current {@link CanvasIdentity.incarnation}." + }, + "revision": { + "type": "number", + "description": "Current {@link CanvasEntry.revision}." + }, + "source": { + "$ref": "#/$defs/CanvasSourcePresentation", + "description": "Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}." } }, "required": [ - "items" + "availability", + "incarnation", + "revision" ] }, - "RunAutomationParams": { + "InvokeCanvasActionParams": { "type": "object", - "description": "Start a manual run of an automation.\n\nManual execution is independent of {@link AutomationDefinition.enabled}.\nThe host persists the run before beginning session side effects.", + "description": "Invokes one of a canvas's currently declared actions exactly once.\n\nThe server MUST reject with `PermissionDenied` (`-32009`) if the canvas's\ncurrent trust is not `trusted`, and with `NotFound` (`-32008`) if\n`actionId` does not match a currently declared action. `incarnation` is\nREQUIRED — omitting stale-generation protection on an effectful call is\nnot allowed. If it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict`\n(`-32011`) rather than route the call to a superseded endpoint.\n\nThe result is the provider's raw reply and is never persisted into\n`CanvasState` — large or provider-specific payloads stay off the durable\nstate tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be\nrepresented out of band instead of being returned inline. Any resulting\nstate changes (e.g. a subsequent availability transition) flow back\nseparately through the normal `canvas/*` action stream on the canvas's\nown channel.\n\nA lost reply (e.g. a dropped connection after the provider already ran\nthe handler) is **indeterminate**: clients MUST NOT automatically replay\n`invokeCanvasAction` on reconnect. Instead, reconnect and read the\ncanvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and\ndecide from observed `revision`/`incarnation` and any provider-visible\nside effect whether to surface the ambiguity to the user, rather than\nassuming success or failure.", "properties": { "channel": { - "type": "string", - "enum": [ - "ahp-automations://" - ], - "description": "Manual runs are scoped to the catalogue channel." + "$ref": "#/$defs/URI", + "description": "The canvas URI." }, "_meta": { "type": "object", "additionalProperties": {}, "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." }, - "automation": { - "$ref": "#/$defs/URI", - "description": "Target {@link AutomationEntry.resource}." + "actionId": { + "type": "string", + "description": "Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions." + }, + "input": { + "description": "Input conforming to the declared action's `inputSchema`/`inputSchemaRef`,\nif any. The JSON-serialized value MUST NOT exceed\n`CANVAS_INPUT_MAX_LENGTH`." + }, + "incarnation": { + "type": "string", + "description": "Expected {@link CanvasIdentity.incarnation}. Required — see above. The\nserver MUST reject the call with `Conflict` if the canvas's live\nendpoint has since been superseded, rather than deliver the call to it." }, "requestId": { "type": "string", - "description": "Durable client-generated idempotency key. Retrying with the same key and\nautomation MUST return the original run URI rather than create another\nrun." + "description": "Durable client-generated idempotency key bounding retry\ndeduplication for this invocation within a live window. The server is\nnot required to guarantee exactly-once execution across a crash. MUST\nNOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`." } }, "required": [ "channel", - "automation", + "actionId", + "incarnation", "requestId" ] }, - "RunAutomationResult": { + "InvokeCanvasActionResult": { "type": "object", - "description": "Result identifying the existing or newly created run.", + "description": "Result of invoking a declared canvas action.", "properties": { - "resource": { - "$ref": "#/$defs/URI", - "description": "Subscribable `ahp-automation-run:` URI matching {@link AutomationRunState.resource}." + "result": { + "description": "The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized." } }, "required": [ - "resource" + "result" ] }, - "FetchAutomationRunsParams": { + "RestartCanvasProviderParams": { "type": "object", - "description": "Load one older page into a catalogued automation's run-history state.\n\nThe response only acknowledges the request. The updated full state arrives\nthrough {@link AutomationSetAction | `automation/set`} on the\n`ahp-automations://` channel, keeping all catalogue subscribers synchronized\nthrough the normal action stream.", + "description": "Explicitly restarts the provider/chat-scoped runtime backing this canvas:\nretires the current live endpoint and establishes a fresh one for the\nsame logical instance.\n\nThis is the **only** operation that intentionally causes an\n{@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource`\n(read-only source resolution / client-local page reload) MUST NEVER\ntrigger it. The host dispatches {@link CanvasAvailabilityChangedAction}\n(transitioning through `notLoaded`/`loading`) and then\n{@link CanvasIncarnationChangedAction} to reflect the outcome. Restart\nnever replays a prior `invokeCanvasAction`, and MUST NOT steal focus or\nrestore any prior in-flight effect.\n\n`incarnation` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasIdentity.incarnation}, so a caller cannot restart a\ngeneration it never observed (e.g. after racing a concurrent restart). A\nlost reply is indeterminate; clients MUST NOT automatically replay this\ncommand — reconnect and compare the canvas's current `incarnation`\ninstead.", "properties": { "channel": { - "type": "string", - "enum": [ - "ahp-automations://" - ], - "description": "Run-history loading is scoped to the catalogue channel." + "$ref": "#/$defs/URI", + "description": "The canvas URI." }, "_meta": { "type": "object", "additionalProperties": {}, "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." }, - "automation": { - "$ref": "#/$defs/URI", - "description": "Target {@link AutomationEntry.resource}." + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." }, - "cursor": { + "incarnation": { "type": "string", - "description": "Cursor previously received as {@link AutomationEntry.runsNextCursor}.\nOmit to request the first page not already included by the snapshot." + "description": "Expected current {@link CanvasIdentity.incarnation}; required — see above." } }, "required": [ "channel", - "automation" + "requestId", + "incarnation" ] }, - "FetchAutomationRunsResult": { + "CloseCanvasParams": { "type": "object", - "description": "Empty acknowledgement; the updated automation state is delivered by action.", - "properties": {} + "description": "Logically closes a canvas: removes its durable membership from\n`SessionState.canvases` and disposes matching views.\n\nThis is distinct from a client merely hiding a local tab or view, which is\npresentation-only and MUST NOT dispatch this command. There is no\nadvertised model tool for this operation — it is invoked only by\nUI/RPC callers.\n\n`revision` is REQUIRED: the server MUST reject with `Conflict`\n(`-32011`) if it does not match the canvas's current\n{@link CanvasEntry.revision}, so a caller cannot close membership state it\nnever actually observed. If no matching entry exists (e.g. already\nclosed), the server MUST treat this as a successful no-op rather than an\nerror — the `revision` precondition only applies when an entry still\nexists. A lost reply is indeterminate; clients MUST NOT automatically\nreplay this command — reconnect and check `SessionState.canvases`\ninstead.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "The canvas URI." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "requestId": { + "type": "string", + "description": "Durable client-generated idempotency key, following the same\nrequestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed\n`CANVAS_REQUEST_ID_MAX_LENGTH`." + }, + "revision": { + "type": "number", + "description": "Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above." + } + }, + "required": [ + "channel", + "requestId", + "revision" + ] }, "URI": { "type": "string", @@ -8030,6 +8763,66 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "ActionEnvelope": { "type": "object", "description": "Every action is wrapped in an `ActionEnvelope`.\n\nThe envelope identifies the channel the action belongs to (e.g.\n`ahp-root://` for root actions, the session URI for session actions, the\nterminal URI for terminal actions). Individual action payloads carry only\nfields that are intrinsic to the action; the channel comes from the\nenvelope so that any subscribable resource can route its actions uniformly.", @@ -8155,6 +8948,12 @@ { "$ref": "#/$defs/SessionMetaChangedAction" }, + { + "$ref": "#/$defs/SessionCanvasSetAction" + }, + { + "$ref": "#/$defs/SessionCanvasRemovedAction" + }, { "$ref": "#/$defs/ChatTurnStartedAction" }, @@ -8346,6 +9145,21 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/CanvasAvailabilityChangedAction" + }, + { + "$ref": "#/$defs/CanvasTrustChangedAction" + }, + { + "$ref": "#/$defs/CanvasIncarnationChangedAction" + }, + { + "$ref": "#/$defs/CanvasTitleChangedAction" + }, + { + "$ref": "#/$defs/CanvasIconChangedAction" } ], "description": "Discriminated union of all state actions." @@ -9088,6 +9902,40 @@ "type" ] }, + "SessionCanvasSetAction": { + "type": "object", + "description": "A canvas was admitted (opened) or its catalog entry changed.\n\nUpsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the\nserver dispatches this with the full entry to record a newly opened\ncanvas, or to republish it after a trust/availability/incarnation change\nso subscribers following only the session channel stay in sync with\n{@link CanvasState}. Never client-dispatchable: admission is through\n`openCanvas` or host publication of a correlated, already-open native\ninstance under that command's admission rules. Both paths MUST use the\nsame singular identity-to-resource binding; repeated native observations\nMUST NOT create a second entry. A stale/out-of-order delivery\n(`canvas.revision` not strictly greater than the currently-recorded entry's\nrevision) MUST be rejected (no-op) rather than overwrite a newer entry with\nolder data.", + "properties": { + "type": { + "const": "session/canvasSet" + }, + "canvas": { + "$ref": "#/$defs/CanvasEntry", + "description": "The canvas entry to add or update, matched by `resource`." + } + }, + "required": [ + "type", + "canvas" + ] + }, + "SessionCanvasRemovedAction": { + "type": "object", + "description": "A canvas was logically closed.\n\nRemove semantics keyed by `resource`: an unknown URI is a no-op. This\nrepresents durable membership removal, not a client hiding a local\ntab/view — see `closeCanvas`.", + "properties": { + "type": { + "const": "session/canvasRemoved" + }, + "resource": { + "$ref": "#/$defs/URI", + "description": "Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}." + } + }, + "required": [ + "type", + "resource" + ] + }, "ChatTurnStartedAction": { "type": "object", "description": "A new message has been sent to the agent, and a new turn starts.\n\nA client is only allowed to send {@link MessageKind.User} messages.", @@ -10586,6 +11434,123 @@ "type" ] }, + "CanvasAvailabilityChangedAction": { + "type": "object", + "description": "Replaces the canvas's live resolution state.\n\nDispatched by the host on every availability transition, including\ninitial resolution after admission by `openCanvas` or a correlated native\nopen, provider restart, and endpoint failure/recovery. A client-local page\nreload or transient presentation credential renewal alone does not require\nthis action or a revision change.", + "properties": { + "type": { + "const": "canvas/availabilityChanged" + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "New {@link CanvasState.availability}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in. The reducer\nMUST reject (no-op) this action if `revision` is not strictly greater\nthan the canvas's current `revision` — this is how stale/out-of-order\ndeliveries are consistently rejected across every canvas action, not\njust this one." + } + }, + "required": [ + "type", + "availability", + "revision" + ] + }, + "CanvasTrustChangedAction": { + "type": "object", + "description": "Replaces the canvas's trust decision.\n\nDispatched by the host whenever the execution-trust decision for this\ncanvas's declared actions changes (e.g. a pending decision resolves, or an\nadministrator revokes a previously trusted source).", + "properties": { + "type": { + "const": "canvas/trustChanged" + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "New {@link CanvasState.trust}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "trust", + "revision" + ] + }, + "CanvasIncarnationChangedAction": { + "type": "object", + "description": "Records that the canvas's live endpoint was replaced by a fresh one for\nthe same logical instance (e.g. the owning provider restarted).\n\nRenewing transient presentation credentials for the same live endpoint is\nnot endpoint replacement and MUST NOT trigger this action.\n\nThe host MUST dispatch {@link CanvasAvailabilityChangedAction} to\ntransition through `notLoaded`/`loading` around this change. Receivers\nMUST reject in-flight `invokeCanvasAction` replies and stale server-pushed\ncallbacks addressed to a superseded `incarnation` — because `incarnation`\nis opaque (see {@link CanvasIdentity.incarnation}), that rejection is\ndriven by the accompanying `revision` bump here, not by comparing\n`incarnation` values for order.", + "properties": { + "type": { + "const": "canvas/incarnationChanged" + }, + "incarnation": { + "type": "string", + "description": "New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "incarnation", + "revision" + ] + }, + "CanvasTitleChangedAction": { + "type": "object", + "description": "Replaces the canvas's display title.", + "properties": { + "type": { + "const": "canvas/titleChanged" + }, + "title": { + "type": "string", + "description": "New {@link CanvasState.title}." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "title", + "revision" + ] + }, + "CanvasIconChangedAction": { + "type": "object", + "description": "Replaces or removes the canvas's display icon.\n\nThis is presentation metadata only. It does not replace the live endpoint,\nchange the canvas incarnation, or replay any canvas effect.", + "properties": { + "type": { + "const": "canvas/iconChanged" + }, + "icon": { + "oneOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "type": "null" + } + ], + "description": "New {@link CanvasState.icon}; `null` removes the current icon." + }, + "revision": { + "type": "number", + "description": "The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}." + } + }, + "required": [ + "type", + "icon", + "revision" + ] + }, "ChatToolCallApprovedAction": { "type": "object", "description": "Client approves a pending tool call. The tool transitions to `running`.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 01271335..af38e85a 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -680,6 +680,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -1069,6 +1072,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted via `openCanvas` or host publication\nof a correlated, already-open native instance under that command's\nadmission rules. Membership is never implied by discovery, subscription,\nsource resolution, a chat's existence, or a client's earlier focus.\nEach entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the\nexact backing chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -5824,6 +5834,462 @@ "sessions" ] }, + "CanvasExtensionSource": { + "type": "object", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", + "properties": { + "kind": { + "const": "extension" + }, + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "extensionId" + ] + }, + "CanvasPackageSource": { + "type": "object", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", + "properties": { + "kind": { + "const": "package" + }, + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." + }, + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "sourceId", + "packageName" + ] + }, + "CanvasIdentityKey": { + "type": "object", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.\n\nThis logical tuple does not widen the owning runtime's native instance-ID\nnamespace. A runtime may require session-wide native IDs across providers;\nhosts MUST preserve that constraint rather than hide native collisions\nwith an invented provider namespace.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId" + ] + }, + "CanvasIdentity": { + "type": "object", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nor transient presentation credential renewal for the same still-live\nendpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) MUST NOT execute or start a provider,\nor open, materialize, or admit a canvas. Membership requires `openCanvas`\nor host publication of a correlated, already-open native instance under\nthat command's admission rules.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient: MUST\nNOT be persisted (including durable canvas/session state or editor\nrestoration data), written to routine logs, or treated as a stable\nidentity. A host MAY embed\nshort-lived, single-use credentials in it; such credentials are never\ndurable authority. Renewed credentials MAY produce a different URL for\nthe same incarnation and revision. Reuse is safe only while the\ncredential is known to remain valid and reusable." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), when\nknown. Omission does not imply indefinite validity or reusability, and\nan unexpired credential may still be single-use." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nMembership is admitted by `openCanvas` or by host publication of a\ncorrelated, already-open native instance under that command's admission\nrules, never by discovery, subscription, or source resolution.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison.\nTransient presentation credential renewal alone does not require a\nrevision change." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -6544,6 +7010,66 @@ } ], "description": "Discriminated lifecycle of an automation run." + }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index 097236df..48f24a24 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -424,6 +424,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/CanvasState" } ], "description": "The current state of the resource" @@ -813,6 +816,13 @@ }, "description": "Catalogue of changesets the server can produce for this session. Each\nentry advertises a subscribable view of file changes (uncommitted,\nsession-wide, per-turn, etc.) and the URI template the client expands\nbefore subscribing. See {@link Changeset} for the full shape and\n{@link /guide/changesets | Changesets} for an overview of the model." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasEntry" + }, + "description": "Catalog of canvases opened for chats in this session. Presence is\ndurable logical membership, admitted via `openCanvas` or host publication\nof a correlated, already-open native instance under that command's\nadmission rules. Membership is never implied by discovery, subscription,\nsource resolution, a chat's existence, or a client's earlier focus.\nEach entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the\nexact backing chat; a canvas never migrates to a different chat. See\n{@link CanvasEntry} for the full membership/availability/trust model." + }, "inputNeeded": { "type": "array", "items": { @@ -5568,6 +5578,462 @@ "sessions" ] }, + "CanvasExtensionSource": { + "type": "object", + "description": "A canvas type provided by an installed host extension.\n\n`extensionId` is the identity-bearing field for comparison purposes (see\n{@link CanvasIdentityKey}). `version` is display/informational metadata\nonly — it MUST NOT be treated as identity-bearing (two `CanvasSource`\nvalues that differ only in `version` are the same source).", + "properties": { + "kind": { + "const": "extension" + }, + "extensionId": { + "type": "string", + "description": "Stable extension identifier (host-defined format, e.g. `publisher.name`).\nMUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "version": { + "type": "string", + "description": "Installed extension version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "extensionId" + ] + }, + "CanvasPackageSource": { + "type": "object", + "description": "A canvas type provided by an installed package that is not a host\nextension (e.g. a workspace-declared runtime package).\n\n`sourceId` — not `packageName` — is the identity-bearing field: the same\ndeclared package name MAY be installed in more than one scope (e.g. a\nworkspace-local copy and a globally-installed copy, or two different\nregistries), and each such installation is a distinct source with its own\n`sourceId`. `packageName` and `version` are display/informational metadata\nonly and MUST NOT be treated as identity-bearing.", + "properties": { + "kind": { + "const": "package" + }, + "sourceId": { + "type": "string", + "description": "Stable, host- or package-manager-assigned unique identifier for this\nspecific installed package instance/scope (opaque format). This is the\nidentity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "packageName": { + "type": "string", + "description": "Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`." + }, + "version": { + "type": "string", + "description": "Installed package version, when known. Metadata only — not identity-bearing." + } + }, + "required": [ + "kind", + "sourceId", + "packageName" + ] + }, + "CanvasIdentityKey": { + "type": "object", + "description": "The logical identity of a canvas, excluding the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.\n\nTwo canvases are the same logical canvas iff `chat`, `canvasType`,\n`instanceId`, and `source`'s **identity-bearing** fields are all equal:\n`kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind`\nplus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and\n`CanvasPackageSource.packageName`) are metadata and MUST NOT factor into\nthis comparison. Clients MUST NOT treat\n{@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key —\nit is only unique within the scope of `(chat, source, canvasType)`.\n\nThis logical tuple does not widen the owning runtime's native instance-ID\nnamespace. A runtime may require session-wide native IDs across providers;\nhosts MUST preserve that constraint rather than hide native collisions\nwith an invented provider namespace.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId" + ] + }, + "CanvasIdentity": { + "type": "object", + "description": "Full identity of a canvas, including the host-assigned\n{@link CanvasIdentity.incarnation | `incarnation`}.", + "properties": { + "chat": { + "$ref": "#/$defs/URI", + "description": "The exact backing chat this canvas belongs to. A canvas is never\nre-associated with a different chat; opening a new one for another chat\ncreates a distinct canvas." + }, + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas's type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format). MUST NOT\nexceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "instanceId": { + "type": "string", + "description": "Provider-chosen stable identifier for this canvas instance, scoped to\n`(chat, source, canvasType)`. Stable across reloads and host/window\nrestarts for the same logical canvas. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "incarnation": { + "type": "string", + "description": "Opaque, host-generated token identifying the current generation of this\ncanvas's live endpoint. The host mints a fresh token whenever a provider\nrestart retires the previous live endpoint and establishes a new one for\nthe same logical instance (see {@link CanvasIncarnationChangedAction |\n`canvas/incarnationChanged`}); it is not changed by a plain page reload\nor transient presentation credential renewal for the same still-live\nendpoint.\n\n`incarnation` is **opaque**: clients and hosts MUST compare it only for\nequality, never parse it, sort it, or perform arithmetic on it (e.g. it\nis not guaranteed to be numeric or monotonically increasing). The host\nMUST NOT reuse a token for this logical identity once it has been\nsuperseded, including across a host/process restart — if the host\ncannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random\nor timestamp-derived) that make accidental reuse practically\nimpossible, rather than a small resettable counter.\n\nClients and hosts use `incarnation` to reject stale callbacks and\nin-flight effects addressed to a superseded endpoint." + } + }, + "required": [ + "chat", + "source", + "canvasType", + "instanceId", + "incarnation" + ] + }, + "CanvasTrustedState": { + "type": "object", + "properties": { + "status": { + "const": "trusted" + } + }, + "required": [ + "status" + ] + }, + "CanvasPendingTrustState": { + "type": "object", + "properties": { + "status": { + "const": "pending" + } + }, + "required": [ + "status" + ] + }, + "CanvasBlockedTrustState": { + "type": "object", + "properties": { + "status": { + "const": "blocked" + }, + "reason": { + "type": "string", + "description": "Optional human-readable reason surfaced to the user." + } + }, + "required": [ + "status" + ] + }, + "CanvasActionDeclaration": { + "type": "object", + "description": "One action a canvas declares it can perform, invoked via\n`invokeCanvasAction`.\n\nDeclarations are carried only on the full {@link CanvasState}, loaded when\na client subscribes — never duplicated into the lightweight\n{@link CanvasEntry} catalog entry, keeping session summaries small.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`." + }, + "title": { + "type": "string", + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "Description of what invoking the action does." + }, + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema for the expected `input`, when small enough to embed\n(see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH},\nchecked by {@link isCanvasSchemaWithinLimits}). Optional because some\ndeclared actions take no input. Mutually exclusive with\n`inputSchemaRef` — a declaration MUST supply at most one of the two." + }, + "inputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger JSON Schema, used instead of\n`inputSchema` when the schema would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined. AHP does not mandate a specific resolution mechanism for this\nURI (e.g. a host MAY make it `resourceRead`-able)." + } + }, + "required": [ + "id" + ] + }, + "CanvasTypeDeclaration": { + "type": "object", + "description": "A canvas type an installed extension or package currently makes available\nto open for a chat, as returned by `listCanvasTypes`.\n\n`CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is\nunrelated to {@link CanvasEntry}, which represents durable membership of\nan already-opened INSTANCE in {@link SessionState.canvases}. Browsing the\ncatalogue (via `listCanvasTypes`) MUST NOT execute or start a provider,\nor open, materialize, or admit a canvas. Membership requires `openCanvas`\nor host publication of a correlated, already-open native instance under\nthat command's admission rules.", + "properties": { + "source": { + "$ref": "#/$defs/CanvasSource", + "description": "The extension or package that declares this canvas type." + }, + "canvasType": { + "type": "string", + "description": "Provider-declared canvas type (host/provider-defined format), passed as\n{@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed\n{@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}." + }, + "title": { + "type": "string", + "description": "Human-readable display name for a canvas-type picker." + }, + "description": { + "type": "string", + "description": "Description of what this canvas type does." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "openInputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "description": "Inline JSON Schema describing the `openCanvas` `input` this type\nexpects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES}\n/ {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with\n`openInputSchemaRef`." + }, + "openInputSchemaRef": { + "$ref": "#/$defs/URI", + "description": "Bounded out-of-band reference to a larger open-input JSON Schema, used\ninstead of `openInputSchema` when it would exceed\n{@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if\ninlined." + }, + "declaredActions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Advisory, statically-known preview of actions this canvas type\ntypically declares once opened (bounded to\n{@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** —\nthe actual invocable actions for an opened instance are always\n{@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g.\ndepend on live provider configuration) and MUST be used instead of this\npreview once the canvas is open." + } + }, + "required": [ + "source", + "canvasType", + "title" + ] + }, + "CanvasSourcePresentation": { + "type": "object", + "description": "Transient, renderer-neutral presentation of a canvas's current live\nendpoint, returned by `resolveCanvasSource`.\n\nThis is a plain URL, not any renderer- or process-model-specific handle\n(e.g. not an Electron `WebContentsView`, a browser tab id, or a webview\npanel reference) — how a client actually presents it (a VS Code Webview,\nthe Integrated Browser, or otherwise) is entirely a client/host\nimplementation detail outside this protocol.", + "properties": { + "url": { + "type": "string", + "description": "Ephemeral URL to the canvas's current live endpoint. Transient: MUST\nNOT be persisted (including durable canvas/session state or editor\nrestoration data), written to routine logs, or treated as a stable\nidentity. A host MAY embed\nshort-lived, single-use credentials in it; such credentials are never\ndurable authority. Renewed credentials MAY produce a different URL for\nthe same incarnation and revision. Reuse is safe only while the\ncredential is known to remain valid and reusable." + }, + "expiresAt": { + "type": "string", + "description": "Advisory expiry hint for `url` (and any embedded credential), when\nknown. Omission does not imply indefinite validity or reusability, and\nan unexpired credential may still be single-use." + } + }, + "required": [ + "url" + ] + }, + "CanvasUnsupportedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "unsupported" + } + }, + "required": [ + "status" + ] + }, + "CanvasNotLoadedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "notLoaded" + } + }, + "required": [ + "status" + ] + }, + "CanvasLoadingAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "loading" + } + }, + "required": [ + "status" + ] + }, + "CanvasEmptyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "empty" + } + }, + "required": [ + "status" + ] + }, + "CanvasReadyAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "ready" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasActionDeclaration" + }, + "description": "Actions currently declared by the live provider (full replacement each time this state is produced)." + } + }, + "required": [ + "status", + "actions" + ] + }, + "CanvasFailedAvailabilityState": { + "type": "object", + "properties": { + "status": { + "const": "failed" + }, + "error": { + "$ref": "#/$defs/ErrorInfo", + "description": "Stable machine-readable and human-readable failure information." + } + }, + "required": [ + "status", + "error" + ] + }, + "CanvasEntry": { + "type": "object", + "description": "Lightweight catalog entry for a canvas, carried in\n{@link SessionState.canvases | `SessionState.canvases`}. Presence\nrepresents durable **logical membership** — it is unaffected by the live\n{@link CanvasEntry.availability | `availability`} cycling through\n`notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times.\n\nMembership is admitted by `openCanvas` or by host publication of a\ncorrelated, already-open native instance under that command's admission\nrules, never by discovery, subscription, or source resolution.\n\nThe full state, including declared actions, lives in {@link CanvasState},\nloaded when a client subscribes to {@link CanvasEntry.resource}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision matching {@link CanvasState.trust}." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityStatus", + "description": "Current availability status matching {@link CanvasState.availability}'s discriminant." + }, + "revision": { + "type": "number", + "description": "Monotonically increasing counter bumped on every change to this\ncanvas's state (trust, availability, or incarnation). Clients MAY use it\nto detect and reject stale reads without a full deep comparison.\nTransient presentation credential renewal alone does not require a\nrevision change." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined summary metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for a single canvas, loaded when a client subscribes to the\ncanvas's URI.\n\n`CanvasState` **denormalizes** every {@link CanvasEntry} field directly\nonto itself, replacing `availability`'s lightweight status with the full\n{@link CanvasAvailabilityState} (including declared actions or failure\ndetail). Producers MUST keep the two representations consistent: any\nchange to the inlined fields SHOULD also be announced on the owning\nsession via {@link SessionCanvasSetAction | `session/canvasSet`}.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "URI of this canvas channel." + }, + "identity": { + "$ref": "#/$defs/CanvasIdentity", + "description": "Full identity, including current incarnation." + }, + "title": { + "type": "string", + "description": "Human-readable display title." + }, + "icon": { + "$ref": "#/$defs/Icon", + "description": "Optional display icon." + }, + "trust": { + "$ref": "#/$defs/CanvasTrustState", + "description": "Current trust decision." + }, + "availability": { + "$ref": "#/$defs/CanvasAvailabilityState", + "description": "Current live resolution state." + }, + "revision": { + "type": "number", + "description": "Matches {@link CanvasEntry.revision}." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Opaque host-defined metadata." + } + }, + "required": [ + "resource", + "identity", + "title", + "trust", + "availability", + "revision" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -6113,6 +6579,54 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "CanvasSource": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasExtensionSource" + }, + { + "$ref": "#/$defs/CanvasPackageSource" + } + ], + "description": "Identifies the explicitly installed extension or package that declares a\ncanvas type. This is provenance for admission and display; it is not a\ngrant of execution trust by itself — see {@link CanvasTrustStatus}." + }, + "CanvasTrustState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasTrustedState" + }, + { + "$ref": "#/$defs/CanvasPendingTrustState" + }, + { + "$ref": "#/$defs/CanvasBlockedTrustState" + } + ], + "description": "Current trust decision governing whether a canvas's declared actions may\nexecute." + }, + "CanvasAvailabilityState": { + "oneOf": [ + { + "$ref": "#/$defs/CanvasUnsupportedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasNotLoadedAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasLoadingAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasEmptyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasReadyAvailabilityState" + }, + { + "$ref": "#/$defs/CanvasFailedAvailabilityState" + } + ], + "description": "Current live resolution state of a canvas." + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -6280,6 +6794,18 @@ ], "type": "string", "description": "Operations the host currently permits for an automation.\n\nThe list on {@link AutomationEntry.operations} is authoritative and may\nchange over time. Clients MUST NOT infer permission from capabilities alone:\ncapabilities describe what the host implementation can support, while\noperations describe what is allowed for this particular automation now." + }, + "CanvasAvailabilityStatus": { + "enum": [ + "unsupported", + "notLoaded", + "loading", + "empty", + "ready", + "failed" + ], + "type": "string", + "description": "Discriminant for {@link CanvasAvailabilityState} — the canvas's current\nlive resolution state, independent of its durable\n{@link CanvasEntry | membership} in a session's catalog.\n\nAn empty catalog membership list is not itself a close, and a canvas may\nremain a recorded member while its live availability cycles through these\nstates any number of times (e.g. across provider restarts)." } } } diff --git a/scripts/find-protocol-sources.ts b/scripts/find-protocol-sources.ts index 79fd2bdc..457d026b 100644 --- a/scripts/find-protocol-sources.ts +++ b/scripts/find-protocol-sources.ts @@ -26,6 +26,7 @@ export const PROTOCOL_SOURCE_DIRS: readonly string[] = [ 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-canvas', ]; /** diff --git a/scripts/generate-action-origin.ts b/scripts/generate-action-origin.ts index b38eb2bb..e356a630 100644 --- a/scripts/generate-action-origin.ts +++ b/scripts/generate-action-origin.ts @@ -17,7 +17,7 @@ const GENERATED_HEADER = `// Generated from types/actions.ts — do not edit // Run \`npm run generate\` to regenerate. `; -type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; +type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun' | 'canvas'; interface ActionInfo { /** The interface name (e.g. 'RootAgentsChangedAction') */ @@ -157,6 +157,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { : category === 'Resource Watch Actions' ? 'resourceWatch' : category === 'Automation Actions' ? 'automation' : category === 'Automation Run Actions' ? 'automationRun' + : category === 'Canvas Actions' ? 'canvas' : 'session'; const isClientDispatchable = hasJsDocTag(node as any, 'clientDispatchable'); @@ -211,6 +212,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const resourceWatchActions = actions.filter(a => a.scope === 'resourceWatch'); const automationActions = actions.filter(a => a.scope === 'automation'); const automationRunActions = actions.filter(a => a.scope === 'automationRun'); + const canvasActions = actions.filter(a => a.scope === 'canvas'); const clientRootActions = rootActions.filter(a => a.isClientDispatchable); const serverRootActions = rootActions.filter(a => !a.isClientDispatchable); const clientSessionActions = sessionActions.filter(a => a.isClientDispatchable); @@ -229,6 +231,8 @@ export function generateActionOrigin(project: Project, outDir: string): void { const serverAutomationActions = automationActions.filter(a => !a.isClientDispatchable); const clientAutomationRunActions = automationRunActions.filter(a => a.isClientDispatchable); const serverAutomationRunActions = automationRunActions.filter(a => !a.isClientDispatchable); + const clientCanvasActions = canvasActions.filter(a => a.isClientDispatchable); + const serverCanvasActions = canvasActions.filter(a => !a.isClientDispatchable); const lines: string[] = [GENERATED_HEADER]; @@ -520,6 +524,34 @@ export function generateActionOrigin(project: Project, outDir: string): void { lines.push(`;`); lines.push(``); + // CanvasAction + lines.push(`/** Union of all canvas-scoped actions. */`); + lines.push(`export type CanvasAction =`); + for (const a of canvasActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of canvas actions that clients may dispatch. */`); + lines.push(`export type ClientCanvasAction =`); + if (clientCanvasActions.length === 0) { + lines.push(` never`); + } else { + for (const a of clientCanvasActions) { + lines.push(` | ${a.name}`); + } + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of canvas actions that only the server may produce. */`); + lines.push(`export type ServerCanvasAction =`); + for (const a of serverCanvasActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); // IS_CLIENT_DISPATCHABLE map lines.push(`// ─── Client-Dispatchable Map ─────────────────────────────────────────────────`); diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 20721221..ddd49540 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -234,6 +234,7 @@ interface CsProp { wireName: string; csType: string; optional: boolean; + requiredNullable: boolean; doc: string; isLiteralDiscriminant: boolean; literalValue?: string; @@ -363,6 +364,7 @@ function extractProps(iface: InterfaceDeclaration, project: Project): CsProp[] { wireName: tsName, csType, optional, + requiredNullable: hasUnionNull && !hasQuestionToken && !hasUnionUndefined, doc: getPropertyDoc(p), isLiteralDiscriminant, literalValue, @@ -527,6 +529,7 @@ function generateCsClass(csName: string, props: CsProp[], opts: StructOpts = {}) // payload is a write-once record with init-only props. const kind = opts.mutable ? 'class' : 'record'; const accessor = opts.mutable ? 'get; set;' : 'get; init;'; + const enforceAllRequiredFields = emittedProps.some((p) => p.requiredNullable); lines.push(`public sealed ${kind} ${csName}`); lines.push('{'); @@ -539,12 +542,16 @@ function generateCsClass(csName: string, props: CsProp[], opts: StructOpts = {}) lines.push(` [JsonPropertyName(${JSON.stringify(p.wireName)})]`); } let csType = p.csType; - if (p.optional) { + if (p.optional && !p.requiredNullable) { lines.push(' [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]'); + } + if (p.optional) { csType = `${csType}?`; } const def = csPropDefault(p.csType, p.optional); - const req = csRequiredModifier(p.csType, p.optional); + const req = enforceAllRequiredFields && !p.optional && !p.isLiteralDiscriminant + ? 'required ' + : csRequiredModifier(p.csType, p.optional && !p.requiredNullable); lines.push(` public ${req}${csType} ${p.csName} { ${accessor} }${def}`); }); lines.push('}'); @@ -568,7 +575,11 @@ function generateClassFromInterface( function generatePartialClass(project: Project, tsInterfaceName: string): string { const iface = findInterface(project, tsInterfaceName); if (!iface) throw new Error(`Interface ${tsInterfaceName} not found`); - const props = extractProps(iface, project).map((p) => ({ ...p, optional: true })); + const props = extractProps(iface, project).map((p) => ({ + ...p, + optional: true, + requiredNullable: false, + })); return generateCsClass(partialCsName(tsInterfaceName), props, { doc: `Partial equivalent of ${stripIPrefix(tsInterfaceName)} — every field is optional for delta updates.`, }); @@ -646,6 +657,7 @@ const STATE_ENUMS = [ 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; // `mutable: true` marks the STATE types the reducers mutate in place — these @@ -803,6 +815,24 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'AutomationCancelledRunLifecycle' }, { name: 'AutomationRunSummary' }, { name: 'AutomationRunState', mutable: true }, + { name: 'CanvasExtensionSource', omitDiscriminants: true }, + { name: 'CanvasPackageSource', omitDiscriminants: true }, + { name: 'CanvasIdentityKey' }, + { name: 'CanvasIdentity' }, + { name: 'CanvasTrustedState', omitDiscriminants: true }, + { name: 'CanvasPendingTrustState', omitDiscriminants: true }, + { name: 'CanvasBlockedTrustState', omitDiscriminants: true }, + { name: 'CanvasActionDeclaration' }, + { name: 'CanvasUnsupportedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasNotLoadedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasLoadingAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEmptyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasReadyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasFailedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEntry', mutable: true }, + { name: 'CanvasState', mutable: true }, + { name: 'CanvasTypeDeclaration' }, + { name: 'CanvasSourcePresentation' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1202,6 +1232,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { ], }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + doc: 'CanvasSource identifies the explicitly installed extension or package that declares a canvas type.', + variants: [ + { variantName: 'Extension', innerType: 'CanvasExtensionSource', wireValue: 'extension' }, + { variantName: 'Package', innerType: 'CanvasPackageSource', wireValue: 'package' }, + ], +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + doc: 'CanvasTrustState is the current trust decision governing whether a canvas\'s declared actions may execute.', + variants: [ + { variantName: 'Trusted', innerType: 'CanvasTrustedState', wireValue: 'trusted' }, + { variantName: 'Pending', innerType: 'CanvasPendingTrustState', wireValue: 'pending' }, + { variantName: 'Blocked', innerType: 'CanvasBlockedTrustState', wireValue: 'blocked' }, + ], +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + doc: 'CanvasAvailabilityState is the current live resolution state of a canvas.', + variants: [ + { variantName: 'Unsupported', innerType: 'CanvasUnsupportedAvailabilityState', wireValue: 'unsupported' }, + { variantName: 'NotLoaded', innerType: 'CanvasNotLoadedAvailabilityState', wireValue: 'notLoaded' }, + { variantName: 'Loading', innerType: 'CanvasLoadingAvailabilityState', wireValue: 'loading' }, + { variantName: 'Empty', innerType: 'CanvasEmptyAvailabilityState', wireValue: 'empty' }, + { variantName: 'Ready', innerType: 'CanvasReadyAvailabilityState', wireValue: 'ready' }, + { variantName: 'Failed', innerType: 'CanvasFailedAvailabilityState', wireValue: 'failed' }, + ], +}; + const CUSTOMIZATION_ENABLEMENT_UNION_CS = `/// A single explicit customization enablement decision. [JsonConverter(typeof(CustomizationEnablementConverter))] public sealed class CustomizationEnablement : AhpUnion @@ -1393,6 +1458,7 @@ function generateStateFile(project: Project): string { MCP_SERVER_STATUS_UNION, TOOL_CALL_CONTRIBUTOR_UNION, SESSION_INPUT_REQUEST_UNION, TERMINAL_LIFECYCLE_STATE_UNION, SESSION_ORIGIN_UNION, AUTOMATION_TRIGGER_UNION, AUTOMATION_RUN_ORIGIN_UNION, AUTOMATION_RUN_LIFECYCLE_UNION, + CANVAS_SOURCE_UNION, CANVAS_TRUST_STATE_UNION, CANVAS_AVAILABILITY_STATE_UNION, ]) { lines.push(generateDiscriminatedUnion(u)); lines.push(''); @@ -1529,6 +1595,13 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string { type: 'automationRun/sessionRemoved', variantName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', variantName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', variantName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', variantName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', variantName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', variantName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', variantName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', variantName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', variantName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, + { type: 'canvas/iconChanged', variantName: 'CanvasIconChanged', tsInterface: 'CanvasIconChangedAction' }, ]; function generateMergedToolCallConfirmedClass(): string { @@ -2081,6 +2154,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str { name: 'Implementation' }, { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, + { name: 'CanvasCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, { name: 'AutomationRunCancellationCapability' }, @@ -2125,6 +2199,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str { name: 'ListAutomationTriggerDefinitionsParams' }, { name: 'ListAutomationTriggerDefinitionsResult' }, { name: 'RunAutomationParams' }, { name: 'RunAutomationResult' }, { name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' }, + { name: 'ListCanvasTypesParams' }, { name: 'ListCanvasTypesResult' }, + { name: 'OpenCanvasParams' }, { name: 'OpenCanvasResult' }, + { name: 'ResolveCanvasSourceParams' }, { name: 'ResolveCanvasSourceResult' }, + { name: 'InvokeCanvasActionParams' }, { name: 'InvokeCanvasActionResult' }, + { name: 'RestartCanvasProviderParams' }, { name: 'CloseCanvasParams' }, ]; const CHAT_SOURCE_UNION: UnionConfig = { @@ -2560,6 +2639,7 @@ function checkExhaustiveness(project: Project): void { 'CustomizationLoadState', 'McpServerState', 'ToolCallContributor', 'SessionOrigin', 'TerminalLifecycleState', 'AutomationTrigger', 'AutomationRunOrigin', 'AutomationRunLifecycle', + 'CanvasSource', 'CanvasTrustState', 'CanvasAvailabilityState', 'SessionInputRequest', 'ToolCallConfirmationState', 'ToolCallRiskAssessment', 'ReconnectResult', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 71926ac2..7e6edabc 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -60,6 +60,11 @@ const HEADER_WITH_IMPORTS = '// json.RawMessage directly (rare but possible). Compiled out.\n' + 'var _ = json.RawMessage(nil)\n'; +const ACTIONS_HEADER_WITH_IMPORTS = HEADER_WITH_IMPORTS.replace( + '\t"encoding/json"\n', + '\t"encoding/json"\n\t"errors"\n', +); + export interface GenerateGoModuleOptions { readonly allowMissingFormatter?: boolean; } @@ -174,6 +179,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState' ) { @@ -245,6 +251,7 @@ interface GoProp { wireName: string; goType: string; optional: boolean; + requiredNullable: boolean; doc: string; /** True iff this property is the union variant's literal discriminant. */ isLiteralDiscriminant: boolean; @@ -347,6 +354,7 @@ function extractProps(iface: InterfaceDeclaration, project: Project): GoProp[] { const { goName, wireName } = goFieldName(tsName); const hasUnionUndefined = /\|\s*undefined/.test(tsType); + const hasUnionNull = /\|\s*null/.test(tsType); const hasQuestionToken = p.hasQuestionToken(); let goType = mapType(tsType); @@ -369,6 +377,7 @@ function extractProps(iface: InterfaceDeclaration, project: Project): GoProp[] { wireName, goType, optional, + requiredNullable: hasUnionNull && !hasQuestionToken && !hasUnionUndefined, doc: getPropertyDoc(p), isLiteralDiscriminant, literalValue, @@ -482,7 +491,7 @@ function generateGoStruct(goName: string, props: GoProp[], opts: StructOpts = {} emitDocComment('\t', p.doc, lines); } const tagParts: string[] = [p.wireName]; - if (p.optional) tagParts.push('omitempty'); + if (p.optional && !p.requiredNullable) tagParts.push('omitempty'); // Box self-referential value types in a pointer so the struct has // a finite size on the stack. let goType = p.goType; @@ -500,6 +509,34 @@ function generateGoStruct(goName: string, props: GoProp[], opts: StructOpts = {} lines.push(`\t${p.goName} ${goType} ${tag}`); } lines.push('}'); + const requiredNullableProps = emittedProps.filter((p) => p.requiredNullable); + if (requiredNullableProps.length > 0) { + const requiredProps = emittedProps.filter( + (p) => (!p.optional || p.requiredNullable) && !p.isLiteralDiscriminant, + ); + lines.push(''); + lines.push(`func (v *${goName}) UnmarshalJSON(data []byte) error {`); + lines.push('\tvar fields map[string]json.RawMessage'); + lines.push('\tif err := json.Unmarshal(data, &fields); err != nil {'); + lines.push('\t\treturn err'); + lines.push('\t}'); + for (const p of requiredProps) { + if (p.requiredNullable) { + lines.push(`\tif _, ok := fields[${JSON.stringify(p.wireName)}]; !ok {`); + lines.push(`\t\treturn errors.New(${JSON.stringify(`${goName}: missing required field ${JSON.stringify(p.wireName)}`)})`); + lines.push('\t}'); + } else { + lines.push(`\tif raw, ok := fields[${JSON.stringify(p.wireName)}]; !ok {`); + lines.push(`\t\treturn errors.New(${JSON.stringify(`${goName}: missing required field ${JSON.stringify(p.wireName)}`)})`); + lines.push('\t} else if string(raw) == "null" {'); + lines.push(`\t\treturn errors.New(${JSON.stringify(`${goName}: required field ${JSON.stringify(p.wireName)} cannot be null`)})`); + lines.push('\t}'); + } + } + lines.push(`\ttype alias ${goName}`); + lines.push('\treturn json.Unmarshal(data, (*alias)(v))'); + lines.push('}'); + } return lines.join('\n'); } @@ -561,9 +598,9 @@ function generatePartialStruct(project: Project, tsInterfaceName: string): strin if (p.optional) return p; // Pointer-ify, except for slice/map (already nilable in Go). if (p.goType.startsWith('*') || p.goType.startsWith('[]') || p.goType.startsWith('map[')) { - return { ...p, optional: true }; + return { ...p, optional: true, requiredNullable: false }; } - return { ...p, optional: true, goType: `*${p.goType}` }; + return { ...p, optional: true, requiredNullable: false, goType: `*${p.goType}` }; }); return generateGoStruct(partialGoName(tsInterfaceName), props, { doc: `Partial${stripIPrefix(tsInterfaceName)} is the partial equivalent of ${stripIPrefix(tsInterfaceName)} — every field is optional for delta updates.`, @@ -728,6 +765,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [ @@ -883,6 +921,24 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'AutomationCancelledRunLifecycle' }, { name: 'AutomationRunSummary' }, { name: 'AutomationRunState' }, + { name: 'CanvasExtensionSource', omitDiscriminants: true }, + { name: 'CanvasPackageSource', omitDiscriminants: true }, + { name: 'CanvasIdentityKey' }, + { name: 'CanvasIdentity' }, + { name: 'CanvasTrustedState', omitDiscriminants: true }, + { name: 'CanvasPendingTrustState', omitDiscriminants: true }, + { name: 'CanvasBlockedTrustState', omitDiscriminants: true }, + { name: 'CanvasActionDeclaration' }, + { name: 'CanvasUnsupportedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasNotLoadedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasLoadingAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEmptyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasReadyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasFailedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEntry' }, + { name: 'CanvasState' }, + { name: 'CanvasTypeDeclaration' }, + { name: 'CanvasSourcePresentation' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1165,6 +1221,44 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { injectDiscriminantOnMarshal: true, }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + doc: 'CanvasSource identifies the explicitly installed extension or package that declares a canvas type.', + variants: [ + { variantName: 'Extension', innerType: 'CanvasExtensionSource', wireValue: 'extension' }, + { variantName: 'Package', innerType: 'CanvasPackageSource', wireValue: 'package' }, + ], + injectDiscriminantOnMarshal: true, +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + doc: 'CanvasTrustState is the current trust decision governing whether a canvas\'s declared actions may execute.', + variants: [ + { variantName: 'Trusted', innerType: 'CanvasTrustedState', wireValue: 'trusted' }, + { variantName: 'Pending', innerType: 'CanvasPendingTrustState', wireValue: 'pending' }, + { variantName: 'Blocked', innerType: 'CanvasBlockedTrustState', wireValue: 'blocked' }, + ], + injectDiscriminantOnMarshal: true, +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + doc: 'CanvasAvailabilityState is the current live resolution state of a canvas.', + variants: [ + { variantName: 'Unsupported', innerType: 'CanvasUnsupportedAvailabilityState', wireValue: 'unsupported' }, + { variantName: 'NotLoaded', innerType: 'CanvasNotLoadedAvailabilityState', wireValue: 'notLoaded' }, + { variantName: 'Loading', innerType: 'CanvasLoadingAvailabilityState', wireValue: 'loading' }, + { variantName: 'Empty', innerType: 'CanvasEmptyAvailabilityState', wireValue: 'empty' }, + { variantName: 'Ready', innerType: 'CanvasReadyAvailabilityState', wireValue: 'ready' }, + { variantName: 'Failed', innerType: 'CanvasFailedAvailabilityState', wireValue: 'failed' }, + ], + injectDiscriminantOnMarshal: true, +}; + function generateChatOriginGo(): string { return `// ChatOrigin describes how a chat came into existence. type ChatOrigin struct { @@ -1499,6 +1593,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateChatOriginGo()); lines.push(''); lines.push(generateSnapshotState()); @@ -1610,6 +1710,13 @@ const ACTION_VARIANTS: { { type: 'automationRun/sessionRemoved', variantName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', variantName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', variantName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', variantName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', variantName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', variantName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', variantName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', variantName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', variantName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, + { type: 'canvas/iconChanged', variantName: 'CanvasIconChanged', tsInterface: 'CanvasIconChangedAction' }, ]; function generateMergedChatToolCallConfirmedStruct(): string { @@ -1670,7 +1777,7 @@ function generateActionsUnion(project: Project): string { } function generateActionsFile(project: Project): string { - const lines: string[] = [HEADER_WITH_IMPORTS]; + const lines: string[] = [ACTIONS_HEADER_WITH_IMPORTS]; lines.push('// ─── ActionType ──────────────────────────────────────────────────────\n'); lines.push(generateActionTypeEnum(project)); @@ -1715,7 +1822,7 @@ const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [ { name: 'InitializeParams' }, { name: 'InitializeResult' }, - { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, + { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, { name: 'CanvasCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, { name: 'AutomationRunCancellationCapability' }, @@ -1752,6 +1859,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'ListAutomationTriggerDefinitionsParams' }, { name: 'ListAutomationTriggerDefinitionsResult' }, { name: 'RunAutomationParams' }, { name: 'RunAutomationResult' }, { name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' }, + { name: 'ListCanvasTypesParams' }, { name: 'ListCanvasTypesResult' }, + { name: 'OpenCanvasParams' }, { name: 'OpenCanvasResult' }, + { name: 'ResolveCanvasSourceParams' }, { name: 'ResolveCanvasSourceResult' }, + { name: 'InvokeCanvasActionParams' }, { name: 'InvokeCanvasActionResult' }, + { name: 'RestartCanvasProviderParams' }, { name: 'CloseCanvasParams' }, ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -2323,6 +2435,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', 'AutomationRunOrigin', 'AutomationRunLifecycle', + 'CanvasSource', + 'CanvasTrustState', + 'CanvasAvailabilityState', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 53985809..45a25099 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -17,6 +17,9 @@ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Project } from 'ts-morph'; import { typeAdmitsUndefined } from './generate-json-schema.js'; +import type { ResolveCanvasSourceResult } from '../types/channels-canvas/commands.js'; +import type { CanvasSourcePresentation } from '../types/channels-canvas/state.js'; +import { CanvasAvailabilityStatus } from '../types/channels-canvas/state.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const SCHEMA_FILES = [ @@ -298,6 +301,58 @@ describe('generated JSON schemas', () => { } }); +describe('canvas presentation schemas', () => { + const commandsSchema = loadSchema('commands.schema.json'); + const resultSchema = { $ref: '#/$defs/ResolveCanvasSourceResult' }; + + it('accepts renewed presentation URLs for the same incarnation and revision with optional expiry', () => { + const presentations: CanvasSourcePresentation[] = [ + { url: 'https://canvas.example/view?credential=first' }, + { url: 'https://canvas.example/view?credential=second', expiresAt: '2026-09-11T12:00:00Z' }, + ]; + + for (const availability of [CanvasAvailabilityStatus.Empty, CanvasAvailabilityStatus.Ready]) { + for (const source of presentations) { + const result: ResolveCanvasSourceResult = { + availability, + incarnation: 'native-endpoint-1', + revision: 7, + source, + }; + assert.equal(schemaAccepts(commandsSchema, resultSchema, result), true); + } + } + }); + + it('still requires revision and incarnation on presentation replies', () => { + assert.equal(schemaAccepts(commandsSchema, resultSchema, { + availability: CanvasAvailabilityStatus.Ready, + incarnation: 'native-endpoint-1', + source: { url: 'https://canvas.example/view?credential=second' }, + }), false); + assert.equal(schemaAccepts(commandsSchema, resultSchema, { + availability: CanvasAvailabilityStatus.Ready, + revision: 7, + source: { url: 'https://canvas.example/view?credential=second' }, + }), false); + }); + + it('does not declare transient presentation fields in durable canvas state or identity', () => { + const stateSchema = loadSchema('state.schema.json'); + for (const name of ['CanvasIdentityKey', 'CanvasIdentity', 'CanvasEntry', 'CanvasState']) { + const definition = dereferenceSchema(stateSchema, { $ref: `#/$defs/${name}` }); + const properties = definition.properties; + assert.ok(properties && typeof properties === 'object' && !Array.isArray(properties)); + assert.equal(Object.hasOwn(properties, 'url'), false, `${name} must not declare a presentation URL`); + assert.equal(Object.hasOwn(properties, 'expiresAt'), false, `${name} must not declare presentation expiry`); + + const targets = new Set(); + collectRefTargets(definition, targets); + assert.equal(targets.has('CanvasSourcePresentation'), false, `${name} must not reference a presentation`); + } + }); +}); + describe('typeAdmitsUndefined', () => { // Guards the depth-aware union splitting these checks depend on: only a // *top-level* `undefined` member means the property may be absent on the diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index ea13b41e..c0ab6049 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -154,6 +154,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || @@ -226,6 +227,7 @@ interface KotlinProp { wireName: string; // JSON key type: string; // Kotlin type optional: boolean; // emit `= null` default + requiredNullable: boolean; doc: string; } @@ -310,7 +312,9 @@ function extractProps(iface: InterfaceDeclaration, project: Project): KotlinProp kt = 'Double'; } const hasUnionUndefined = /\|\s*undefined/.test(tsType); - const isOptional = p.hasQuestionToken() || hasUnionUndefined || kt.endsWith('?'); + const hasUnionNull = /\|\s*null/.test(tsType); + const hasQuestionToken = p.hasQuestionToken(); + const isOptional = hasQuestionToken || hasUnionUndefined || kt.endsWith('?'); const finalType = isOptional && !kt.endsWith('?') ? kt + '?' : kt; const kName = tsName.startsWith('_') ? kotlinPropName(tsName) @@ -323,6 +327,7 @@ function extractProps(iface: InterfaceDeclaration, project: Project): KotlinProp wireName: tsName, type: finalType, optional: isOptional, + requiredNullable: hasUnionNull && !hasQuestionToken && !hasUnionUndefined, doc: getPropertyDoc(p), }; }); @@ -472,8 +477,11 @@ function generateKotlinDataClass( props: KotlinProp[], ): string { const lines: string[] = []; + const requiredNullable = props.filter(p => p.requiredNullable); - lines.push('@Serializable'); + lines.push(requiredNullable.length > 0 + ? `@Serializable(with = ${ktName}Serializer::class)` + : '@Serializable'); if (props.length === 0) { lines.push(`class ${ktName}`); @@ -489,12 +497,63 @@ function generateKotlinDataClass( if (p.name !== p.wireName) { lines.push(` @SerialName(${JSON.stringify(p.wireName)})`); } - const defaultVal = p.optional ? ' = null' : ''; + const defaultVal = p.optional && !p.requiredNullable ? ' = null' : ''; const trailing = idx === props.length - 1 ? '' : ','; lines.push(` val ${p.name}: ${p.type}${defaultVal}${trailing}`); }); lines.push(')'); + if (requiredNullable.length > 0) { + const wireName = `${ktName}Wire`; + lines.push(''); + lines.push('@Serializable'); + lines.push(`private data class ${wireName}(`); + props.forEach((p, idx) => { + if (p.name !== p.wireName) { + lines.push(` @SerialName(${JSON.stringify(p.wireName)})`); + } + const defaultVal = p.optional && !p.requiredNullable ? ' = null' : ''; + const trailing = idx === props.length - 1 ? '' : ','; + lines.push(` val ${p.name}: ${p.type}${defaultVal}${trailing}`); + }); + lines.push(')'); + lines.push(''); + lines.push(`internal object ${ktName}Serializer : KSerializer<${ktName}> {`); + lines.push(` override val descriptor: SerialDescriptor = ${wireName}.serializer().descriptor`); + lines.push(''); + lines.push(` override fun deserialize(decoder: Decoder): ${ktName} {`); + lines.push(' val input = decoder as? JsonDecoder'); + lines.push(` ?: error(${JSON.stringify(`${ktName} can only be deserialized from JSON`)})`); + lines.push(' val element = input.decodeJsonElement()'); + lines.push(' val obj = element as? JsonObject'); + lines.push(` ?: throw kotlinx.serialization.SerializationException(${JSON.stringify(`Expected JsonObject for ${ktName}`)})`); + for (const p of requiredNullable) { + lines.push(` if (!obj.containsKey(${JSON.stringify(p.wireName)})) throw kotlinx.serialization.SerializationException(${JSON.stringify(`${ktName}: missing required field "${p.wireName}"`)})`); + } + lines.push(` val wire = input.json.decodeFromJsonElement(${wireName}.serializer(), element)`); + lines.push(` return ${ktName}(`); + for (const p of props) { + lines.push(` ${p.name} = wire.${p.name},`); + } + lines.push(' )'); + lines.push(' }'); + lines.push(''); + lines.push(` override fun serialize(encoder: Encoder, value: ${ktName}) {`); + lines.push(' val output = encoder as? JsonEncoder'); + lines.push(` ?: error(${JSON.stringify(`${ktName} can only be serialized to JSON`)})`); + lines.push(` val wire = ${wireName}(`); + for (const p of props) { + lines.push(` ${p.name} = value.${p.name},`); + } + lines.push(' )'); + lines.push(` var element: JsonElement = output.json.encodeToJsonElement(${wireName}.serializer(), wire)`); + for (const p of requiredNullable) { + lines.push(` if (value.${p.name} == null) element = JsonObject(element.jsonObject + (${JSON.stringify(p.wireName)} to kotlinx.serialization.json.JsonNull))`); + } + lines.push(' output.encodeJsonElement(element)'); + lines.push(' }'); + lines.push('}'); + } return lines.join('\n'); } @@ -735,6 +794,7 @@ function generatePartialDataClassFromInterface( const props = extractProps(iface, project).map(p => ({ ...p, optional: true, + requiredNullable: false, type: p.type.endsWith('?') ? p.type : `${p.type}?`, })); return generateKotlinDataClass(partialKotlinName(tsInterfaceName), props); @@ -980,6 +1040,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; const STATE_STRUCTS = [ @@ -1048,6 +1109,13 @@ const STATE_STRUCTS = [ 'AutomationCompletedRunLifecycle', 'AutomationFailedRunLifecycle', 'AutomationCancelledRunLifecycle', 'AutomationRunSummary', 'AutomationRunState', + 'CanvasExtensionSource', 'CanvasPackageSource', 'CanvasIdentityKey', 'CanvasIdentity', + 'CanvasTrustedState', 'CanvasPendingTrustState', 'CanvasBlockedTrustState', + 'CanvasActionDeclaration', + 'CanvasUnsupportedAvailabilityState', 'CanvasNotLoadedAvailabilityState', + 'CanvasLoadingAvailabilityState', 'CanvasEmptyAvailabilityState', + 'CanvasReadyAvailabilityState', 'CanvasFailedAvailabilityState', + 'CanvasEntry', 'CanvasState', 'CanvasTypeDeclaration', 'CanvasSourcePresentation', ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1363,6 +1431,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { injectDiscriminantOnSerialize: true, }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + variants: [ + { caseName: 'Extension', structName: 'CanvasExtensionSource', discriminantValue: 'extension' }, + { caseName: 'Package', structName: 'CanvasPackageSource', discriminantValue: 'package' }, + ], + injectDiscriminantOnSerialize: true, +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + variants: [ + { caseName: 'Trusted', structName: 'CanvasTrustedState', discriminantValue: 'trusted' }, + { caseName: 'Pending', structName: 'CanvasPendingTrustState', discriminantValue: 'pending' }, + { caseName: 'Blocked', structName: 'CanvasBlockedTrustState', discriminantValue: 'blocked' }, + ], + injectDiscriminantOnSerialize: true, +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + variants: [ + { caseName: 'Unsupported', structName: 'CanvasUnsupportedAvailabilityState', discriminantValue: 'unsupported' }, + { caseName: 'NotLoaded', structName: 'CanvasNotLoadedAvailabilityState', discriminantValue: 'notLoaded' }, + { caseName: 'Loading', structName: 'CanvasLoadingAvailabilityState', discriminantValue: 'loading' }, + { caseName: 'Empty', structName: 'CanvasEmptyAvailabilityState', discriminantValue: 'empty' }, + { caseName: 'Ready', structName: 'CanvasReadyAvailabilityState', discriminantValue: 'ready' }, + { caseName: 'Failed', structName: 'CanvasFailedAvailabilityState', discriminantValue: 'failed' }, + ], + injectDiscriminantOnSerialize: true, +}; + function generateStateFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1454,6 +1557,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateToolResultContentUnion()); lines.push(''); lines.push(generateSnapshotState()); @@ -1561,6 +1670,13 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'automationRun/sessionRemoved', caseName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', caseName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', caseName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', caseName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', caseName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', caseName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', caseName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', caseName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', caseName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, + { type: 'canvas/iconChanged', caseName: 'CanvasIconChanged', tsInterface: 'CanvasIconChangedAction' }, ]; /** Merged data class for the approved/denied tool call confirmed action. */ @@ -1721,7 +1837,7 @@ const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding const COMMAND_STRUCTS = [ 'InitializeParams', 'InitializeResult', - 'ClientCapabilities', 'AutomationCapabilities', + 'ClientCapabilities', 'AutomationCapabilities', 'CanvasCapabilities', 'AutomationCreateCapability', 'AutomationScheduleCapabilities', 'AutomationRunCancellationCapability', @@ -1755,6 +1871,11 @@ const COMMAND_STRUCTS = [ 'ListAutomationTriggerDefinitionsParams', 'ListAutomationTriggerDefinitionsResult', 'RunAutomationParams', 'RunAutomationResult', 'FetchAutomationRunsParams', 'FetchAutomationRunsResult', + 'ListCanvasTypesParams', 'ListCanvasTypesResult', + 'OpenCanvasParams', 'OpenCanvasResult', + 'ResolveCanvasSourceParams', 'ResolveCanvasSourceResult', + 'InvokeCanvasActionParams', 'InvokeCanvasActionResult', + 'RestartCanvasProviderParams', 'CloseCanvasParams', ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -2366,6 +2487,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union 'AutomationRunOrigin', // AUTOMATION_RUN_ORIGIN_UNION discriminated union 'AutomationRunLifecycle', // AUTOMATION_RUN_LIFECYCLE_UNION discriminated union + 'CanvasSource', // CANVAS_SOURCE_UNION discriminated union + 'CanvasTrustState', // CANVAS_TRUST_STATE_UNION discriminated union + 'CanvasAvailabilityState', // CANVAS_AVAILABILITY_STATE_UNION discriminated union 'ForkChatSource', // generateFixedChatSourceBranchKotlin() 'SideChatSource', // generateFixedChatSourceBranchKotlin() 'ChangesetOperationTarget', // generateChangesetOperationTargetKotlin() diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 4e999bbc..24c3f38e 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -164,6 +164,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -221,6 +222,7 @@ interface RustProp { wireName: string; rustType: string; optional: boolean; + requiredNullable: boolean; renamed: boolean; doc: string; isLiteralDiscriminant: boolean; @@ -319,6 +321,7 @@ function extractProps(iface: InterfaceDeclaration, project: Project): RustProp[] const { rustName, wireName, renamed } = rustFieldName(tsName); const hasUnionUndefined = /\|\s*undefined/.test(tsType); + const hasUnionNull = /\|\s*null/.test(tsType); const hasQuestionToken = p.hasQuestionToken(); let rustType = mapType(tsType, tsName, iface.getName()); @@ -336,6 +339,7 @@ function extractProps(iface: InterfaceDeclaration, project: Project): RustProp[] wireName, rustType, optional, + requiredNullable: hasUnionNull && !hasQuestionToken && !hasUnionUndefined, renamed, doc: getPropertyDoc(p), isLiteralDiscriminant, @@ -601,10 +605,13 @@ function generateRustStruct(rustName: string, props: RustProp[], opts: StructOpt } const attrs: string[] = []; if (p.renamed) attrs.push(`rename = ${JSON.stringify(p.wireName)}`); - if (p.optional) { + if (p.optional && !p.requiredNullable) { attrs.push('default'); attrs.push('skip_serializing_if = "Option::is_none"'); } + if (p.requiredNullable) { + attrs.push('deserialize_with = "deserialize_required_nullable"'); + } if (rustName === 'SessionToolClientExecutionRequest' && p.rustName === 'tool_call') { attrs.push('serialize_with = "serialize_running_tool_call"'); attrs.push('deserialize_with = "deserialize_running_tool_call"'); @@ -653,6 +660,18 @@ where }`; } +function generateRequiredNullableSerdeHelper(): string { + return `fn deserialize_required_nullable<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Option::::deserialize(deserializer) +}`; +} + // ─── Partial Struct Generation ─────────────────────────────────────────────── function generatePartialStruct(project: Project, tsInterfaceName: string): string { @@ -663,6 +682,7 @@ function generatePartialStruct(project: Project, tsInterfaceName: string): strin return { ...p, optional: true, + requiredNullable: false, rustType: p.rustType.startsWith('Option<') ? p.rustType : `Option<${p.rustType}>`, }; }); @@ -708,7 +728,10 @@ function generateDiscriminatedUnion(project: Project, cfg: UnionConfig): string if (cfg.doc) { for (const d of cfg.doc.split('\n')) lines.push(`/// ${d.trimEnd()}`); } - lines.push('#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]'); + const derives = unknown + ? '#[derive(Debug, Clone, PartialEq, Serialize)]' + : '#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]'; + lines.push(derives); lines.push(`#[serde(tag = ${JSON.stringify(cfg.discriminantField)})]`); lines.push(`pub enum ${cfg.name} {`); @@ -733,6 +756,35 @@ function generateDiscriminatedUnion(project: Project, cfg: UnionConfig): string } lines.push('}'); + if (unknown) { + lines.push(''); + lines.push(`impl<'de> Deserialize<'de> for ${cfg.name} {`); + lines.push(' fn deserialize(deserializer: D) -> Result'); + lines.push(' where'); + lines.push(` D: serde::Deserializer<'de>,`); + lines.push(' {'); + lines.push(' let raw = serde_json::Value::deserialize(deserializer)?;'); + lines.push(` let discriminator = raw.get(${JSON.stringify(cfg.discriminantField)})`); + lines.push(' .and_then(serde_json::Value::as_str);'); + lines.push(' match discriminator {'); + for (const v of cfg.variants) { + if (v.isUnit) { + lines.push(` Some(${JSON.stringify(v.wireValue)}) => Ok(Self::${v.variantName}),`); + } else { + lines.push(` Some(${JSON.stringify(v.wireValue)}) => serde_json::from_value::<${v.innerType}>(raw)`); + if (v.boxed) { + lines.push(` .map(|value| Self::${v.variantName}(Box::new(value)))`); + } else { + lines.push(` .map(Self::${v.variantName})`); + } + lines.push(' .map_err(serde::de::Error::custom),'); + } + } + lines.push(' _ => Ok(Self::Unknown(raw)),'); + lines.push(' }'); + lines.push(' }'); + lines.push('}'); + } return lines.join('\n'); } @@ -768,6 +820,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; /** @@ -944,6 +997,24 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'AutomationCancelledRunLifecycle', omitDiscriminants: true }, { name: 'AutomationRunSummary' }, { name: 'AutomationRunState' }, + { name: 'CanvasExtensionSource', omitDiscriminants: true }, + { name: 'CanvasPackageSource', omitDiscriminants: true }, + { name: 'CanvasIdentityKey' }, + { name: 'CanvasIdentity' }, + { name: 'CanvasTrustedState', omitDiscriminants: true }, + { name: 'CanvasPendingTrustState', omitDiscriminants: true }, + { name: 'CanvasBlockedTrustState', omitDiscriminants: true }, + { name: 'CanvasActionDeclaration' }, + { name: 'CanvasUnsupportedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasNotLoadedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasLoadingAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEmptyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasReadyAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasFailedAvailabilityState', omitDiscriminants: true }, + { name: 'CanvasEntry' }, + { name: 'CanvasState' }, + { name: 'CanvasTypeDeclaration' }, + { name: 'CanvasSourcePresentation' }, ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1227,6 +1298,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { ], }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + doc: 'Identifies the explicitly installed extension or package that declares a canvas type.', + variants: [ + { variantName: 'Extension', innerType: 'CanvasExtensionSource', wireValue: 'extension' }, + { variantName: 'Package', innerType: 'CanvasPackageSource', wireValue: 'package' }, + ], +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + doc: 'Current trust decision governing whether a canvas\'s declared actions may execute.', + variants: [ + { variantName: 'Trusted', innerType: 'CanvasTrustedState', wireValue: 'trusted' }, + { variantName: 'Pending', innerType: 'CanvasPendingTrustState', wireValue: 'pending' }, + { variantName: 'Blocked', innerType: 'CanvasBlockedTrustState', wireValue: 'blocked' }, + ], +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + doc: 'Current live resolution state of a canvas.', + variants: [ + { variantName: 'Unsupported', innerType: 'CanvasUnsupportedAvailabilityState', wireValue: 'unsupported' }, + { variantName: 'NotLoaded', innerType: 'CanvasNotLoadedAvailabilityState', wireValue: 'notLoaded' }, + { variantName: 'Loading', innerType: 'CanvasLoadingAvailabilityState', wireValue: 'loading' }, + { variantName: 'Empty', innerType: 'CanvasEmptyAvailabilityState', wireValue: 'empty' }, + { variantName: 'Ready', innerType: 'CanvasReadyAvailabilityState', wireValue: 'ready' }, + { variantName: 'Failed', innerType: 'CanvasFailedAvailabilityState', wireValue: 'failed' }, + ], +}; + function generateChatOrigin(project: Project): string { const originKind = findEnum(project, 'ChatOriginKind'); if (!originKind) throw new Error('ChatOriginKind enum not found'); @@ -1401,6 +1507,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateSnapshotState()); lines.push(''); @@ -1513,6 +1625,13 @@ const ACTION_VARIANTS: { { type: 'automationRun/sessionRemoved', variantName: 'AutomationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', variantName: 'AutomationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', variantName: 'AutomationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', variantName: 'SessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', variantName: 'SessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', variantName: 'CanvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction', boxed: true }, + { type: 'canvas/trustChanged', variantName: 'CanvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', variantName: 'CanvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', variantName: 'CanvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, + { type: 'canvas/iconChanged', variantName: 'CanvasIconChanged', tsInterface: 'CanvasIconChangedAction' }, ]; function generateMergedToolCallConfirmedStruct(scope: 'Session' | 'Chat' = 'Session'): string { @@ -1590,9 +1709,8 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, CanvasAvailabilityState, CanvasEntry, CanvasTrustState, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, Icon, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); - // ActionType enum lines.push('// ─── ActionType ──────────────────────────────────────────────────────\n'); const actionTypeEnum = findEnum(project, 'ActionType'); @@ -1629,6 +1747,8 @@ pub struct ActionEnvelope { // Individual action structs (as variant inner types — omit the `type` field) lines.push('// ─── Action Payloads ─────────────────────────────────────────────────\n'); + lines.push(generateRequiredNullableSerdeHelper()); + lines.push(''); const priorPartials = new Set(requiredPartialStructs); for (const v of ACTION_VARIANTS) { if (v.tsInterface === '_merged_' || v.tsInterface === '_merged_chat_') { @@ -1698,7 +1818,7 @@ const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: string }[] = [ { name: 'InitializeParams' }, { name: 'InitializeResult' }, - { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, + { name: 'ClientCapabilities' }, { name: 'AutomationCapabilities' }, { name: 'CanvasCapabilities' }, { name: 'AutomationCreateCapability' }, { name: 'AutomationScheduleCapabilities' }, { name: 'AutomationRunCancellationCapability' }, @@ -1736,6 +1856,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'ListAutomationTriggerDefinitionsParams' }, { name: 'ListAutomationTriggerDefinitionsResult' }, { name: 'RunAutomationParams' }, { name: 'RunAutomationResult' }, { name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' }, + { name: 'ListCanvasTypesParams' }, { name: 'ListCanvasTypesResult' }, + { name: 'OpenCanvasParams' }, { name: 'OpenCanvasResult' }, + { name: 'ResolveCanvasSourceParams' }, { name: 'ResolveCanvasSourceResult' }, + { name: 'InvokeCanvasActionParams' }, { name: 'InvokeCanvasActionResult' }, + { name: 'RestartCanvasProviderParams' }, { name: 'CloseCanvasParams' }, ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -1763,7 +1888,7 @@ function generateCommandsFile(project: Project): string { lines.push('#[allow(unused_imports)]'); lines.push('use crate::actions::{ActionEnvelope, StateAction};'); lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, AutomationTrigger, AutomationTriggerDefinition, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); + lines.push('use crate::state::{AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, AutomationTrigger, AutomationTriggerDefinition, CanvasAvailabilityStatus, CanvasEntry, CanvasIdentityKey, CanvasSourcePresentation, CanvasTypeDeclaration, ContentRef, Icon, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); @@ -2230,6 +2355,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', 'AutomationRunOrigin', 'AutomationRunLifecycle', + 'CanvasSource', + 'CanvasTrustState', + 'CanvasAvailabilityState', 'AuthRequiredErrorData', 'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData', diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 89fa9bcd..c33bdc00 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -120,6 +120,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -187,6 +188,7 @@ interface SwiftProp { wireName: string; // JSON key type: string; // Swift type optional: boolean; + requiredNullable: boolean; doc: string; } @@ -272,7 +274,9 @@ function extractProps(iface: InterfaceDeclaration, project: Project): SwiftProp[ swiftT = 'Double'; } const hasUnionUndefined = /\|\s*undefined/.test(tsType); - const isOptional = p.hasQuestionToken() || hasUnionUndefined || swiftT.endsWith('?'); + const hasUnionNull = /\|\s*null/.test(tsType); + const hasQuestionToken = p.hasQuestionToken(); + const isOptional = hasQuestionToken || hasUnionUndefined || swiftT.endsWith('?'); const finalType = isOptional && !swiftT.endsWith('?') ? swiftT + '?' : swiftT; const sName = tsName.startsWith('_') ? swiftPropName(tsName) @@ -285,6 +289,7 @@ function extractProps(iface: InterfaceDeclaration, project: Project): SwiftProp[ wireName: tsName, type: finalType, optional: isOptional, + requiredNullable: hasUnionNull && !hasQuestionToken && !hasUnionUndefined, doc: getPropertyDoc(p), }; }); @@ -437,7 +442,8 @@ function generateSwiftStruct( } // CodingKeys if needed - const needsKeys = props.some(p => p.name !== p.wireName); + const hasRequiredNullable = props.some(p => p.requiredNullable); + const needsKeys = hasRequiredNullable || props.some(p => p.name !== p.wireName); if (needsKeys) { lines.push(''); lines.push(' enum CodingKeys: String, CodingKey {'); @@ -454,7 +460,7 @@ function generateSwiftStruct( // Public init lines.push(''); const initParams = props.map(p => { - const defaultVal = p.optional ? ' = nil' : ''; + const defaultVal = p.optional && !p.requiredNullable ? ' = nil' : ''; return ` ${p.name}: ${p.type}${defaultVal}`; }); lines.push(' public init('); @@ -465,6 +471,30 @@ function generateSwiftStruct( } lines.push(' }'); + if (hasRequiredNullable) { + lines.push(''); + lines.push(' public init(from decoder: Decoder) throws {'); + lines.push(' let container = try decoder.container(keyedBy: CodingKeys.self)'); + for (const p of props) { + if (p.requiredNullable) { + lines.push(` self.${p.name} = try container.decode(${p.type}.self, forKey: .${p.name})`); + } else if (p.optional) { + lines.push(` self.${p.name} = try container.decodeIfPresent(${p.type.replace(/\?$/, '')}.self, forKey: .${p.name})`); + } else { + lines.push(` self.${p.name} = try container.decode(${p.type}.self, forKey: .${p.name})`); + } + } + lines.push(' }'); + lines.push(''); + lines.push(' public func encode(to encoder: Encoder) throws {'); + lines.push(' var container = encoder.container(keyedBy: CodingKeys.self)'); + for (const p of props) { + const method = p.optional && !p.requiredNullable ? 'encodeIfPresent' : 'encode'; + lines.push(` try container.${method}(${p.name}, forKey: .${p.name})`); + } + lines.push(' }'); + } + lines.push('}'); return lines.join('\n'); } @@ -509,7 +539,7 @@ function generateDiscriminatedUnion(project: Project, config: UnionConfig): stri lines.push(`public enum ${config.name}: Codable, Sendable {`); for (const v of config.variants) { - lines.push(` case ${v.caseName}(${v.structName})`); + lines.push(` case ${swiftIdentifier(v.caseName)}(${v.structName})`); } if (allowUnknown) { lines.push(' /// Unknown or future discriminant; the raw payload is preserved'); @@ -537,7 +567,7 @@ function generateDiscriminatedUnion(project: Project, config: UnionConfig): stri lines.push(' switch discriminant {'); for (const v of config.variants) { lines.push(` case ${JSON.stringify(v.discriminantValue)}:`); - lines.push(` self = .${v.caseName}(try ${v.structName}(from: decoder))`); + lines.push(` self = .${swiftIdentifier(v.caseName)}(try ${v.structName}(from: decoder))`); } lines.push(' default:'); if (allowUnknown) { @@ -554,11 +584,11 @@ function generateDiscriminatedUnion(project: Project, config: UnionConfig): stri lines.push(' switch self {'); for (const v of config.variants) { if (config.injectDiscriminantOnEncode) { - lines.push(` case .${v.caseName}(var value):`); - lines.push(` value.${config.discriminantField} = .${v.caseName}`); + lines.push(` case .${swiftIdentifier(v.caseName)}(var value):`); + lines.push(` value.${config.discriminantField} = .${swiftIdentifier(v.caseName)}`); lines.push(' try value.encode(to: encoder)'); } else { - lines.push(` case .${v.caseName}(let value): try value.encode(to: encoder)`); + lines.push(` case .${swiftIdentifier(v.caseName)}(let value): try value.encode(to: encoder)`); } } if (allowUnknown) { @@ -662,6 +692,7 @@ function generatePartialStructFromInterface( const props = extractProps(iface, project).map(p => ({ ...p, optional: true, + requiredNullable: false, type: p.type.endsWith('?') ? p.type : `${p.type}?`, })); return generateSwiftStruct(partialSwiftName(tsInterfaceName), props); @@ -685,6 +716,7 @@ const STATE_ENUMS = [ 'SessionOriginKind', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationRunStatus', 'AutomationRunOriginKind', + 'CanvasSourceKind', 'CanvasTrustStatus', 'CanvasAvailabilityStatus', ]; const STATE_STRUCTS = [ @@ -753,6 +785,13 @@ const STATE_STRUCTS = [ 'AutomationCompletedRunLifecycle', 'AutomationFailedRunLifecycle', 'AutomationCancelledRunLifecycle', 'AutomationRunSummary', 'AutomationRunState', + 'CanvasExtensionSource', 'CanvasPackageSource', 'CanvasIdentityKey', 'CanvasIdentity', + 'CanvasTrustedState', 'CanvasPendingTrustState', 'CanvasBlockedTrustState', + 'CanvasActionDeclaration', + 'CanvasUnsupportedAvailabilityState', 'CanvasNotLoadedAvailabilityState', + 'CanvasLoadingAvailabilityState', 'CanvasEmptyAvailabilityState', + 'CanvasReadyAvailabilityState', 'CanvasFailedAvailabilityState', + 'CanvasEntry', 'CanvasState', 'CanvasTypeDeclaration', 'CanvasSourcePresentation', ]; const RESPONSE_PART_UNION: UnionConfig = { @@ -1267,6 +1306,41 @@ const AUTOMATION_RUN_LIFECYCLE_UNION: UnionConfig = { injectDiscriminantOnEncode: true, }; +const CANVAS_SOURCE_UNION: UnionConfig = { + name: 'CanvasSource', + discriminantField: 'kind', + variants: [ + { caseName: 'extension', structName: 'CanvasExtensionSource', discriminantValue: 'extension' }, + { caseName: 'package', structName: 'CanvasPackageSource', discriminantValue: 'package' }, + ], + injectDiscriminantOnEncode: true, +}; + +const CANVAS_TRUST_STATE_UNION: UnionConfig = { + name: 'CanvasTrustState', + discriminantField: 'status', + variants: [ + { caseName: 'trusted', structName: 'CanvasTrustedState', discriminantValue: 'trusted' }, + { caseName: 'pending', structName: 'CanvasPendingTrustState', discriminantValue: 'pending' }, + { caseName: 'blocked', structName: 'CanvasBlockedTrustState', discriminantValue: 'blocked' }, + ], + injectDiscriminantOnEncode: true, +}; + +const CANVAS_AVAILABILITY_STATE_UNION: UnionConfig = { + name: 'CanvasAvailabilityState', + discriminantField: 'status', + variants: [ + { caseName: 'unsupported', structName: 'CanvasUnsupportedAvailabilityState', discriminantValue: 'unsupported' }, + { caseName: 'notLoaded', structName: 'CanvasNotLoadedAvailabilityState', discriminantValue: 'notLoaded' }, + { caseName: 'loading', structName: 'CanvasLoadingAvailabilityState', discriminantValue: 'loading' }, + { caseName: 'empty', structName: 'CanvasEmptyAvailabilityState', discriminantValue: 'empty' }, + { caseName: 'ready', structName: 'CanvasReadyAvailabilityState', discriminantValue: 'ready' }, + { caseName: 'failed', structName: 'CanvasFailedAvailabilityState', discriminantValue: 'failed' }, + ], + injectDiscriminantOnEncode: true, +}; + function generateStateFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; @@ -1350,6 +1424,12 @@ function generateStateFile(project: Project): string { lines.push(''); lines.push(generateDiscriminatedUnion(project, AUTOMATION_RUN_LIFECYCLE_UNION)); lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_SOURCE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_TRUST_STATE_UNION)); + lines.push(''); + lines.push(generateDiscriminatedUnion(project, CANVAS_AVAILABILITY_STATE_UNION)); + lines.push(''); lines.push(generateToolResultContentUnion()); lines.push(''); lines.push(generateSnapshotState()); @@ -1458,6 +1538,13 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'automationRun/sessionRemoved', caseName: 'automationRunSessionRemoved', tsInterface: 'AutomationRunSessionRemovedAction' }, { type: 'automationRun/primarySessionChanged', caseName: 'automationRunPrimarySessionChanged', tsInterface: 'AutomationRunPrimarySessionChangedAction' }, { type: 'automationRun/cancelRequested', caseName: 'automationRunCancelRequested', tsInterface: 'AutomationRunCancelRequestedAction' }, + { type: 'session/canvasSet', caseName: 'sessionCanvasSet', tsInterface: 'SessionCanvasSetAction' }, + { type: 'session/canvasRemoved', caseName: 'sessionCanvasRemoved', tsInterface: 'SessionCanvasRemovedAction' }, + { type: 'canvas/availabilityChanged', caseName: 'canvasAvailabilityChanged', tsInterface: 'CanvasAvailabilityChangedAction' }, + { type: 'canvas/trustChanged', caseName: 'canvasTrustChanged', tsInterface: 'CanvasTrustChangedAction' }, + { type: 'canvas/incarnationChanged', caseName: 'canvasIncarnationChanged', tsInterface: 'CanvasIncarnationChangedAction' }, + { type: 'canvas/titleChanged', caseName: 'canvasTitleChanged', tsInterface: 'CanvasTitleChangedAction' }, + { type: 'canvas/iconChanged', caseName: 'canvasIconChanged', tsInterface: 'CanvasIconChangedAction' }, ]; /** Merged struct for the approved/denied tool call confirmed action */ @@ -1627,7 +1714,7 @@ function generateActionsFile(project: Project): string { const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode']; const COMMAND_STRUCTS = [ - 'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'AutomationCapabilities', + 'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'AutomationCapabilities', 'CanvasCapabilities', 'AutomationCreateCapability', 'AutomationScheduleCapabilities', 'AutomationRunCancellationCapability', @@ -1661,6 +1748,11 @@ const COMMAND_STRUCTS = [ 'ListAutomationTriggerDefinitionsParams', 'ListAutomationTriggerDefinitionsResult', 'RunAutomationParams', 'RunAutomationResult', 'FetchAutomationRunsParams', 'FetchAutomationRunsResult', + 'ListCanvasTypesParams', 'ListCanvasTypesResult', + 'OpenCanvasParams', 'OpenCanvasResult', + 'ResolveCanvasSourceParams', 'ResolveCanvasSourceResult', + 'InvokeCanvasActionParams', 'InvokeCanvasActionResult', + 'RestartCanvasProviderParams', 'CloseCanvasParams', ]; const RECONNECT_RESULT_UNION: UnionConfig = { @@ -2383,6 +2475,9 @@ function checkExhaustiveness(project: Project): void { 'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union 'AutomationRunOrigin', // AUTOMATION_RUN_ORIGIN_UNION discriminated union 'AutomationRunLifecycle', // AUTOMATION_RUN_LIFECYCLE_UNION discriminated union + 'CanvasSource', // CANVAS_SOURCE_UNION discriminated union + 'CanvasTrustState', // CANVAS_TRUST_STATE_UNION discriminated union + 'CanvasAvailabilityState', // CANVAS_AVAILABILITY_STATE_UNION discriminated union 'ForkChatSource', // generateFixedChatSourceBranchSwift() 'SideChatSource', // generateFixedChatSourceBranchSwift() 'ChangesetOperationTarget', // TS discriminated union; consumers should add a Swift case-iterable enum diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index b3686f61..130d0c50 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -35,6 +35,8 @@ import type { SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction, + SessionCanvasSetAction, + SessionCanvasRemovedAction, ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, @@ -99,6 +101,11 @@ import type { AutomationRunSessionRemovedAction, AutomationRunPrimarySessionChangedAction, AutomationRunCancelRequestedAction, + CanvasAvailabilityChangedAction, + CanvasTrustChangedAction, + CanvasIncarnationChangedAction, + CanvasTitleChangedAction, + CanvasIconChangedAction, } from './actions.js'; import { ActionType } from './actions.js'; @@ -155,6 +162,8 @@ export type SessionAction = | SessionChangesetsChangedAction | SessionConfigChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction ; /** Union of session actions that clients may dispatch. */ @@ -191,6 +200,8 @@ export type ServerSessionAction = | SessionActivityChangedAction | SessionChangesetsChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction ; /** Union of all chat-scoped actions. */ @@ -407,6 +418,29 @@ export type ServerAutomationRunAction = | AutomationRunPrimarySessionChangedAction ; +/** Union of all canvas-scoped actions. */ +export type CanvasAction = + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction + | CanvasIconChangedAction +; + +/** Union of canvas actions that clients may dispatch. */ +export type ClientCanvasAction = + never +; + +/** Union of canvas actions that only the server may produce. */ +export type ServerCanvasAction = + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction + | CanvasIconChangedAction +; + // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** @@ -446,6 +480,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionChangesetsChanged]: false, [ActionType.SessionConfigChanged]: true, [ActionType.SessionMetaChanged]: false, + [ActionType.SessionCanvasSet]: false, + [ActionType.SessionCanvasRemoved]: false, [ActionType.ChatTurnStarted]: true, [ActionType.ChatDelta]: false, [ActionType.ChatResponsePart]: false, @@ -510,4 +546,9 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.AutomationRunSessionRemoved]: false, [ActionType.AutomationRunPrimarySessionChanged]: false, [ActionType.AutomationRunCancelRequested]: true, + [ActionType.CanvasAvailabilityChanged]: false, + [ActionType.CanvasTrustChanged]: false, + [ActionType.CanvasIncarnationChanged]: false, + [ActionType.CanvasTitleChanged]: false, + [ActionType.CanvasIconChanged]: false, }; diff --git a/types/actions.ts b/types/actions.ts index 97ec41dc..292f37e0 100644 --- a/types/actions.ts +++ b/types/actions.ts @@ -17,3 +17,4 @@ export * from './channels-annotations/actions.js'; export * from './channels-resource-watch/actions.js'; export * from './channels-automation/actions.js'; export * from './channels-automation-run/actions.js'; +export * from './channels-canvas/actions.js'; diff --git a/types/channels-canvas.test.ts b/types/channels-canvas.test.ts new file mode 100644 index 00000000..937dff68 --- /dev/null +++ b/types/channels-canvas.test.ts @@ -0,0 +1,63 @@ +/** + * Boundary tests for the canvas channel's declared, enforceable size/depth + * limits (`CANVAS_SCHEMA_MAX_PROPERTIES`, `CANVAS_SCHEMA_MAX_DEPTH`, and the + * `isCanvasSchemaWithinLimits` helper hosts use to enforce them). These are + * protocol-mandated ceilings, not measured workload numbers — see + * `types/channels-canvas/state.ts` for the full contract. + * + * Run: npx tsx --test types/channels-canvas.test.ts + */ + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + CANVAS_SCHEMA_MAX_PROPERTIES, + CANVAS_SCHEMA_MAX_DEPTH, + isCanvasSchemaWithinLimits, +} from './channels-canvas/state.js'; + +/** Builds an object schema with `count` trivial top-level properties. */ +function schemaWithProperties(count: number): { properties: Record } { + const properties: Record = {}; + for (let i = 0; i < count; i++) { + properties[`p${i}`] = { type: 'string' }; + } + return { properties }; +} + +/** Builds a schema nested `depth` levels deep (depth 1 = no nested `properties`). */ +function schemaWithDepth(depth: number): { properties: Record } { + let inner: { properties?: Record } = {}; + for (let level = depth; level > 1; level--) { + inner = { properties: { child: inner } }; + } + return { properties: { child: inner } }; +} + +describe('isCanvasSchemaWithinLimits', () => { + it('accepts a schema with no properties', () => { + assert.equal(isCanvasSchemaWithinLimits({}), true); + }); + + it('accepts a schema at exactly CANVAS_SCHEMA_MAX_PROPERTIES', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithProperties(CANVAS_SCHEMA_MAX_PROPERTIES)), true); + }); + + it('rejects a schema exceeding CANVAS_SCHEMA_MAX_PROPERTIES by one', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithProperties(CANVAS_SCHEMA_MAX_PROPERTIES + 1)), false); + }); + + it('accepts a schema nested at exactly CANVAS_SCHEMA_MAX_DEPTH', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithDepth(CANVAS_SCHEMA_MAX_DEPTH)), true); + }); + + it('rejects a schema nested one level past CANVAS_SCHEMA_MAX_DEPTH', () => { + assert.equal(isCanvasSchemaWithinLimits(schemaWithDepth(CANVAS_SCHEMA_MAX_DEPTH + 1)), false); + }); + + it('rejects a schema whose properties exceed the bound only at a nested level', () => { + const nested = schemaWithProperties(CANVAS_SCHEMA_MAX_PROPERTIES + 1); + assert.equal(isCanvasSchemaWithinLimits({ properties: { child: nested } }), false); + }); +}); diff --git a/types/channels-canvas/actions.ts b/types/channels-canvas/actions.ts new file mode 100644 index 00000000..f5910823 --- /dev/null +++ b/types/channels-canvas/actions.ts @@ -0,0 +1,117 @@ +/** + * Canvas Channel Actions — Mutations of an `ahp-canvas:` channel's state. + * + * All actions here are server-dispatched: canvas live state reflects + * authoritative resolution outcomes, not client-optimistic writes. Clients + * request effects through the `openCanvas`, `invokeCanvasAction`, + * `restartCanvasProvider`, and `closeCanvas` commands instead. + * + * @module channels-canvas/actions + */ + +import { ActionType } from '../common/actions.js'; +import type { Icon } from '../common/state.js'; +import type { CanvasAvailabilityState, CanvasTrustState } from './state.js'; + +// ─── Canvas Actions ────────────────────────────────────────────────────────── + +/** + * Replaces the canvas's live resolution state. + * + * Dispatched by the host on every availability transition, including + * initial resolution after admission by `openCanvas` or a correlated native + * open, provider restart, and endpoint failure/recovery. A client-local page + * reload or transient presentation credential renewal alone does not require + * this action or a revision change. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasAvailabilityChangedAction { + type: ActionType.CanvasAvailabilityChanged; + /** New {@link CanvasState.availability}. */ + availability: CanvasAvailabilityState; + /** + * The {@link CanvasState.revision} this action results in. The reducer + * MUST reject (no-op) this action if `revision` is not strictly greater + * than the canvas's current `revision` — this is how stale/out-of-order + * deliveries are consistently rejected across every canvas action, not + * just this one. + */ + revision: number; +} + +/** + * Replaces the canvas's trust decision. + * + * Dispatched by the host whenever the execution-trust decision for this + * canvas's declared actions changes (e.g. a pending decision resolves, or an + * administrator revokes a previously trusted source). + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasTrustChangedAction { + type: ActionType.CanvasTrustChanged; + /** New {@link CanvasState.trust}. */ + trust: CanvasTrustState; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Records that the canvas's live endpoint was replaced by a fresh one for + * the same logical instance (e.g. the owning provider restarted). + * + * Renewing transient presentation credentials for the same live endpoint is + * not endpoint replacement and MUST NOT trigger this action. + * + * The host MUST dispatch {@link CanvasAvailabilityChangedAction} to + * transition through `notLoaded`/`loading` around this change. Receivers + * MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed + * callbacks addressed to a superseded `incarnation` — because `incarnation` + * is opaque (see {@link CanvasIdentity.incarnation}), that rejection is + * driven by the accompanying `revision` bump here, not by comparing + * `incarnation` values for order. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasIncarnationChangedAction { + type: ActionType.CanvasIncarnationChanged; + /** New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. */ + incarnation: string; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Replaces the canvas's display title. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasTitleChangedAction { + type: ActionType.CanvasTitleChanged; + /** New {@link CanvasState.title}. */ + title: string; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Replaces or removes the canvas's display icon. + * + * This is presentation metadata only. It does not replace the live endpoint, + * change the canvas incarnation, or replay any canvas effect. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasIconChangedAction { + type: ActionType.CanvasIconChanged; + /** New {@link CanvasState.icon}; `null` removes the current icon. */ + icon: Icon | null; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} diff --git a/types/channels-canvas/commands.ts b/types/channels-canvas/commands.ts new file mode 100644 index 00000000..5c22eac2 --- /dev/null +++ b/types/channels-canvas/commands.ts @@ -0,0 +1,374 @@ +/** + * Canvas Channel Commands — `listCanvasTypes`, `openCanvas`, + * `resolveCanvasSource`, `invokeCanvasAction`, `restartCanvasProvider`, and + * `closeCanvas`. + * + * @module channels-canvas/commands + */ + +import type { URI, Icon } from '../common/state.js'; +import type { BaseParams, PaginatedParams, PaginatedResult } from '../common/commands.js'; +import type { + CanvasAvailabilityStatus, + CanvasEntry, + CanvasIdentityKey, + CanvasSourcePresentation, + CanvasTypeDeclaration, +} from './state.js'; + +// ─── listCanvasTypes ───────────────────────────────────────────────────────── + +/** + * Discovers canvas TYPES currently available to open for one exact backing + * chat. + * + * This is a **pure read/browse** operation: it MUST NOT execute or start a + * provider, or open, materialize, or otherwise admit any canvas. See + * `openCanvas` for the admission rules, including publication of an + * already-open native instance. This catalogue is unrelated to + * {@link SessionState.canvases}, which reflects durable membership of + * already-opened canvas INSTANCES, not the set of canvas TYPES a + * host/extension could open; do not confuse the two. + * + * @category Commands + * @method listCanvasTypes + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ListCanvasTypesParams extends BaseParams, PaginatedParams { + /** The exact backing chat to discover available canvas types for. */ + channel: URI; +} + +/** + * Available canvas types for the requested chat. + * + * @category Commands + */ +export interface ListCanvasTypesResult extends PaginatedResult { + /** Discovered canvas type declarations. */ + types: CanvasTypeDeclaration[]; +} + +// ─── openCanvas ────────────────────────────────────────────────────────────── + +/** + * Explicitly opens (admits) a canvas, associating it with the owning chat + * given by `identity.chat` at the moment of the call — never with whichever + * chat later happens to have focus. + * + * Canvas membership requires explicit admission. A client admits a canvas + * by calling `openCanvas`, a read-write operation. `listCanvasTypes`, + * `subscribe`, and `resolveCanvasSource` MUST NOT admit a canvas or execute + * or start its provider. + * + * A host MAY also publish membership after observing an instance already + * opened by the owning native runtime. Before publication, the host MUST + * correlate the observation to the actual backing chat, canonical source, + * canvas type, and native instance, and enforce applicable + * execution-admission policy. Uncorrelated or conflicting observations MUST + * be rejected rather than assigned to the focused chat or a guessed source. + * Observation does not grant execution trust: it MUST NOT convert `pending` + * or `blocked` trust to `trusted`; trust and availability remain independent. + * + * Native publication follows the same singular identity-to-resource binding + * and authoritative state rules as client-originated admission. The host + * MUST NOT manufacture a client `openCanvas` request or invoke the provider's + * open handler again merely to publish an already-open instance. Repeated + * observations MUST NOT duplicate membership; this does not suppress the + * actual effects of a genuinely new native open. Hosts MUST preserve the + * native instance-ID namespace, including session-wide IDs across providers + * where the owning runtime requires them, rather than hide collisions with + * an invented provider namespace. Client `requestId` semantics are unchanged. + * + * Once admitted by either path, clients read and follow live state by + * `subscribe`-ing to `canvas.resource`, and resolve the current live endpoint + * via `resolveCanvasSource`; neither read opens, resumes, or restarts anything. + * + * **Logical identity is always singular.** The same {@link CanvasIdentityKey} + * (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same + * `canvas` resource URI and the same {@link SessionState.canvases} catalog + * entry, no matter how many times `openCanvas` is called or a native open is + * observed for it. The server MUST reuse that existing entry's `resource` + * rather than mint a second one. A client-supplied `canvas` URI is honored + * only on the call that first establishes the identity; on a later call for + * an already-recorded identity the server MUST ignore the supplied `canvas` + * value and return the existing resource instead. + * + * **Idempotency is scoped to `requestId`, not identity.** Retrying with the + * exact same `requestId` and byte-for-byte identical params from the same + * authenticated connection MUST return the original result without + * repeating any side effect, within a bounded live window (the server is + * not required to remember it forever). Reusing the same `requestId` with + * any different parameter value MUST be rejected with `Conflict` + * (`-32011`) — mint a new `requestId` for a new logical call. A genuinely + * NEW `requestId` for an already-open identity MAY be effectful (e.g. + * updating `title`/`icon`, or causing the provider to re-run its own + * open-time initialization with new `input`) — this mirrors the pinned + * SDK's own repeated-open behavior and does not create a second logical + * identity. There is no exactly-once-across-crash guarantee: a lost reply + * is indeterminate, and clients MUST NOT automatically replay `openCanvas` + * — reconnect and read `SessionState.canvases` / `resolveCanvasSource` + * instead to determine the actual outcome. + * + * @category Commands + * @method openCanvas + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface OpenCanvasParams extends BaseParams { + /** Session URI that will list the opened canvas in `SessionState.canvases`. */ + channel: URI; + /** Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. */ + canvas: URI; + /** Logical identity to open or re-admit. */ + identity: CanvasIdentityKey; + /** Initial (or updated, on a later effectful call) display title. */ + title: string; + /** Initial (or updated) display icon. */ + icon?: Icon; + /** + * Bounded JSON input for this open call (e.g. seed parameters the + * provider uses to initialize the canvas), opaque to the protocol. See + * {@link CanvasTypeDeclaration.openInputSchema} / + * `openInputSchemaRef` for the expected shape. The JSON-serialized value + * MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + */ + input?: unknown; + /** + * Durable client-generated idempotency key bounding retry deduplication + * for this call within a live window; see the idempotency rules above. + * MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; +} + +/** + * Result identifying the existing or newly opened canvas. + * + * @category Commands + */ +export interface OpenCanvasResult { + /** The catalog entry for the opened (or already-open) canvas. */ + canvas: CanvasEntry; +} + +// ─── resolveCanvasSource ───────────────────────────────────────────────────── + +/** + * Reads a canvas's current live-resolution state and, when currently live, + * a transient endpoint presentation. + * + * This is read-only with respect to membership, provider execution/lifecycle, + * and durable canvas state. It MUST NOT admit a canvas or create, resume, + * reopen, or restart a provider, including on unavailable or unauthorized + * requests. Authorization failure MUST NOT expose `source`. + * + * For an already-live endpoint, authorized resolution MAY issue or refresh + * transient presentation credentials while constructing the response. Two + * resolutions of the same live incarnation MAY therefore return different + * URLs. Credential refresh alone MUST NOT rerun provider open, change the + * incarnation, or require a canvas state revision. If the live state changes + * concurrently during resolution, the response MUST report that state's + * current availability, revision, and incarnation; any returned `source` + * MUST correspond to that reported state. + * + * Clients MUST NOT replace a newer attachment with a superseded resolution + * response, even when credential renewal leaves `revision` and `incarnation` + * unchanged. These state guards do not order same-state credential refreshes. + * + * If the canvas does not currently have a live endpoint, `source` is absent + * and `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`). + * Call `restartCanvasProvider` (an explicitly effectful operation) to + * attempt recovery instead; calling `resolveCanvasSource` again only + * retries reading the current state without restarting anything. + * + * A client-local page reload needs no provider restart or new effectful + * command. Before reload or reattachment, the client SHOULD resolve a fresh + * presentation unless the existing credential is known to remain valid and + * reusable. An absent expiry hint does not imply indefinite validity or + * reusability. Presentation URLs and credentials MUST NOT enter durable + * membership, editor restoration data, or routine logs. + * + * @category Commands + * @method resolveCanvasSource + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ResolveCanvasSourceParams extends BaseParams { + /** The canvas URI (an already-opened canvas's `resource`). */ + channel: URI; +} + +/** + * The canvas's current live-resolution state as of this read. + * + * @category Commands + */ +export interface ResolveCanvasSourceResult { + /** Current {@link CanvasEntry.availability}. */ + availability: CanvasAvailabilityStatus; + /** Current {@link CanvasIdentity.incarnation}. */ + incarnation: string; + /** Current {@link CanvasEntry.revision}. */ + revision: number; + /** Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. */ + source?: CanvasSourcePresentation; +} + +// ─── invokeCanvasAction ────────────────────────────────────────────────────── + +/** + * Invokes one of a canvas's currently declared actions exactly once. + * + * The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's + * current trust is not `trusted`, and with `NotFound` (`-32008`) if + * `actionId` does not match a currently declared action. `incarnation` is + * REQUIRED — omitting stale-generation protection on an effectful call is + * not allowed. If it does not match the canvas's current + * {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` + * (`-32011`) rather than route the call to a superseded endpoint. + * + * The result is the provider's raw reply and is never persisted into + * `CanvasState` — large or provider-specific payloads stay off the durable + * state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be + * represented out of band instead of being returned inline. Any resulting + * state changes (e.g. a subsequent availability transition) flow back + * separately through the normal `canvas/*` action stream on the canvas's + * own channel. + * + * A lost reply (e.g. a dropped connection after the provider already ran + * the handler) is **indeterminate**: clients MUST NOT automatically replay + * `invokeCanvasAction` on reconnect. Instead, reconnect and read the + * canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and + * decide from observed `revision`/`incarnation` and any provider-visible + * side effect whether to surface the ambiguity to the user, rather than + * assuming success or failure. + * + * @category Commands + * @method invokeCanvasAction + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface InvokeCanvasActionParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. */ + actionId: string; + /** + * Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + * if any. The JSON-serialized value MUST NOT exceed + * `CANVAS_INPUT_MAX_LENGTH`. + */ + input?: unknown; + /** + * Expected {@link CanvasIdentity.incarnation}. Required — see above. The + * server MUST reject the call with `Conflict` if the canvas's live + * endpoint has since been superseded, rather than deliver the call to it. + */ + incarnation: string; + /** + * Durable client-generated idempotency key bounding retry + * deduplication for this invocation within a live window. The server is + * not required to guarantee exactly-once execution across a crash. MUST + * NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; +} + +/** + * Result of invoking a declared canvas action. + * + * @category Commands + */ +export interface InvokeCanvasActionResult { + /** The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. */ + result: unknown; +} + +// ─── restartCanvasProvider ─────────────────────────────────────────────────── + +/** + * Explicitly restarts the provider/chat-scoped runtime backing this canvas: + * retires the current live endpoint and establishes a fresh one for the + * same logical instance. + * + * This is the **only** operation that intentionally causes an + * {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` + * (read-only source resolution / client-local page reload) MUST NEVER + * trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} + * (transitioning through `notLoaded`/`loading`) and then + * {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart + * never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or + * restore any prior in-flight effect. + * + * `incarnation` is REQUIRED: the server MUST reject with `Conflict` + * (`-32011`) if it does not match the canvas's current + * {@link CanvasIdentity.incarnation}, so a caller cannot restart a + * generation it never observed (e.g. after racing a concurrent restart). A + * lost reply is indeterminate; clients MUST NOT automatically replay this + * command — reconnect and compare the canvas's current `incarnation` + * instead. + * + * @category Commands + * @method restartCanvasProvider + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface RestartCanvasProviderParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; + /** Expected current {@link CanvasIdentity.incarnation}; required — see above. */ + incarnation: string; +} + +// ─── closeCanvas ───────────────────────────────────────────────────────────── + +/** + * Logically closes a canvas: removes its durable membership from + * `SessionState.canvases` and disposes matching views. + * + * This is distinct from a client merely hiding a local tab or view, which is + * presentation-only and MUST NOT dispatch this command. There is no + * advertised model tool for this operation — it is invoked only by + * UI/RPC callers. + * + * `revision` is REQUIRED: the server MUST reject with `Conflict` + * (`-32011`) if it does not match the canvas's current + * {@link CanvasEntry.revision}, so a caller cannot close membership state it + * never actually observed. If no matching entry exists (e.g. already + * closed), the server MUST treat this as a successful no-op rather than an + * error — the `revision` precondition only applies when an entry still + * exists. A lost reply is indeterminate; clients MUST NOT automatically + * replay this command — reconnect and check `SessionState.canvases` + * instead. + * + * @category Commands + * @method closeCanvas + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface CloseCanvasParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; + /** Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. */ + revision: number; +} diff --git a/types/channels-canvas/reducer.ts b/types/channels-canvas/reducer.ts new file mode 100644 index 00000000..f48a2917 --- /dev/null +++ b/types/channels-canvas/reducer.ts @@ -0,0 +1,72 @@ +/** + * Canvas Channel Reducer — Pure reducer for `CanvasState`. + * + * @module channels-canvas/reducer + */ + +import { ActionType } from '../common/actions.js'; +import type { CanvasAction } from '../action-origin.generated.js'; +import type { CanvasState } from './state.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; + +/** + * Pure reducer for canvas state. Handles all {@link CanvasAction} variants. + * + * Every variant carries the `revision` it results in. This reducer rejects + * (no-ops) any action whose `revision` is not strictly greater than the + * canvas's current `revision`, so a stale or out-of-order delivery can never + * overwrite newer state — including a hypothetical stale + * `canvas/incarnationChanged` reverting `identity.incarnation` to a + * superseded value. Applying an action always sets `state.revision` to the + * action's asserted `revision` (never a reducer-computed increment), keeping + * the contract consistent across all four action types. + */ +export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (msg: string) => void): CanvasState { + switch (action.type) { + case ActionType.CanvasAvailabilityChanged: + if (action.revision <= state.revision) { + return state; + } + return { ...state, availability: action.availability, revision: action.revision }; + + case ActionType.CanvasTrustChanged: + if (action.revision <= state.revision) { + return state; + } + return { ...state, trust: action.trust, revision: action.revision }; + + case ActionType.CanvasIncarnationChanged: + if (action.revision <= state.revision) { + return state; + } + return { + ...state, + identity: { ...state.identity, incarnation: action.incarnation }, + revision: action.revision, + }; + + case ActionType.CanvasTitleChanged: + if (action.revision <= state.revision) { + return state; + } + return { ...state, title: action.title, revision: action.revision }; + + case ActionType.CanvasIconChanged: + if (action.revision <= state.revision) { + return state; + } + if (action.icon === null) { + const { icon: _, ...withoutIcon } = state; + return { ...withoutIcon, revision: action.revision }; + } + return { + ...state, + icon: action.icon, + revision: action.revision, + }; + + default: + softAssertNever(action, log); + return state; + } +} diff --git a/types/channels-canvas/state.ts b/types/channels-canvas/state.ts new file mode 100644 index 00000000..c452596c --- /dev/null +++ b/types/channels-canvas/state.ts @@ -0,0 +1,632 @@ +/** + * Canvas State Types — Local canvas identity and lifecycle exposed on + * `ahp-canvas:` channels. + * + * A "canvas" is a provider-rendered surface (e.g. a local extension or + * installed package's custom view) associated with one exact backing chat. + * AHP models the canvas's identity, durable membership, admission/trust and + * live availability state machine, and its declared actions — never the + * rendering transport, page bytes, or a provider-specific SDK type. Hosts + * remain authoritative for how a canvas is actually served; this channel + * only carries what a client needs to discover, admit, and interact with one. + * + * Stability: 1.0 - Early development + * + * @module channels-canvas/state + */ + +import type { ErrorInfo, Icon, URI } from '../common/state.js'; + +// ─── Canvas Identity ───────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasSource} — what kind of package originates a + * canvas type. + * + * @category Canvas Identity + * @nonexhaustive + */ +export const enum CanvasSourceKind { + /** An explicitly installed host extension. */ + Extension = 'extension', + /** An explicitly installed package (not a host extension). */ + Package = 'package', +} + +/** + * A canvas type provided by an installed host extension. + * + * `extensionId` is the identity-bearing field for comparison purposes (see + * {@link CanvasIdentityKey}). `version` is display/informational metadata + * only — it MUST NOT be treated as identity-bearing (two `CanvasSource` + * values that differ only in `version` are the same source). + * + * @category Canvas Identity + */ +export interface CanvasExtensionSource { + kind: CanvasSourceKind.Extension; + /** + * Stable extension identifier (host-defined format, e.g. `publisher.name`). + * MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + extensionId: string; + /** Installed extension version, when known. Metadata only — not identity-bearing. */ + version?: string; +} + +/** + * A canvas type provided by an installed package that is not a host + * extension (e.g. a workspace-declared runtime package). + * + * `sourceId` — not `packageName` — is the identity-bearing field: the same + * declared package name MAY be installed in more than one scope (e.g. a + * workspace-local copy and a globally-installed copy, or two different + * registries), and each such installation is a distinct source with its own + * `sourceId`. `packageName` and `version` are display/informational metadata + * only and MUST NOT be treated as identity-bearing. + * + * @category Canvas Identity + */ +export interface CanvasPackageSource { + kind: CanvasSourceKind.Package; + /** + * Stable, host- or package-manager-assigned unique identifier for this + * specific installed package instance/scope (opaque format). This is the + * identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + sourceId: string; + /** Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. */ + packageName: string; + /** Installed package version, when known. Metadata only — not identity-bearing. */ + version?: string; +} + +/** + * Identifies the explicitly installed extension or package that declares a + * canvas type. This is provenance for admission and display; it is not a + * grant of execution trust by itself — see {@link CanvasTrustStatus}. + * + * @category Canvas Identity + */ +export type CanvasSource = CanvasExtensionSource | CanvasPackageSource; + +/** + * The logical identity of a canvas, excluding the host-assigned + * {@link CanvasIdentity.incarnation | `incarnation`}. + * + * Two canvases are the same logical canvas iff `chat`, `canvasType`, + * `instanceId`, and `source`'s **identity-bearing** fields are all equal: + * `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` + * plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and + * `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into + * this comparison. Clients MUST NOT treat + * {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — + * it is only unique within the scope of `(chat, source, canvasType)`. + * + * This logical tuple does not widen the owning runtime's native instance-ID + * namespace. A runtime may require session-wide native IDs across providers; + * hosts MUST preserve that constraint rather than hide native collisions + * with an invented provider namespace. + * + * @category Canvas Identity + */ +export interface CanvasIdentityKey { + /** + * The exact backing chat this canvas belongs to. A canvas is never + * re-associated with a different chat; opening a new one for another chat + * creates a distinct canvas. + */ + chat: URI; + /** The extension or package that declares this canvas's type. */ + source: CanvasSource; + /** + * Provider-declared canvas type (host/provider-defined format). MUST NOT + * exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + canvasType: string; + /** + * Provider-chosen stable identifier for this canvas instance, scoped to + * `(chat, source, canvasType)`. Stable across reloads and host/window + * restarts for the same logical canvas. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + instanceId: string; +} + +/** + * Full identity of a canvas, including the host-assigned + * {@link CanvasIdentity.incarnation | `incarnation`}. + * + * @category Canvas Identity + */ +export interface CanvasIdentity extends CanvasIdentityKey { + /** + * Opaque, host-generated token identifying the current generation of this + * canvas's live endpoint. The host mints a fresh token whenever a provider + * restart retires the previous live endpoint and establishes a new one for + * the same logical instance (see {@link CanvasIncarnationChangedAction | + * `canvas/incarnationChanged`}); it is not changed by a plain page reload + * or transient presentation credential renewal for the same still-live + * endpoint. + * + * `incarnation` is **opaque**: clients and hosts MUST compare it only for + * equality, never parse it, sort it, or perform arithmetic on it (e.g. it + * is not guaranteed to be numeric or monotonically increasing). The host + * MUST NOT reuse a token for this logical identity once it has been + * superseded, including across a host/process restart — if the host + * cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + * or timestamp-derived) that make accidental reuse practically + * impossible, rather than a small resettable counter. + * + * Clients and hosts use `incarnation` to reject stale callbacks and + * in-flight effects addressed to a superseded endpoint. + */ + incarnation: string; +} + +// ─── Limits ────────────────────────────────────────────────────────────────── + +/** + * Maximum UTF-16 code units in a `requestId` (`openCanvas`, + * `invokeCanvasAction`, `restartCanvasProvider`, `closeCanvas`). Hosts MUST + * reject a longer value with `InvalidParams` (`-32602`) rather than + * truncate it. + * + * @category Canvas Limits + */ +export const CANVAS_REQUEST_ID_MAX_LENGTH = 256; + +/** + * Maximum UTF-16 code units in any single identity-bearing string field: + * {@link CanvasIdentityKey.canvasType}, {@link CanvasIdentityKey.instanceId}, + * {@link CanvasExtensionSource.extensionId}, or + * {@link CanvasPackageSource.sourceId}. Hosts MUST reject a longer value + * with `InvalidParams` (`-32602`) rather than truncate it. + * + * @category Canvas Limits + */ +export const CANVAS_IDENTITY_FIELD_MAX_LENGTH = 256; + +/** + * Maximum number of top-level `properties` entries an inline JSON Schema + * (`CanvasActionDeclaration.inputSchema` / + * `CanvasTypeDeclaration.openInputSchema`) may declare at any single nesting + * level. A schema that would exceed this MUST instead be represented via + * `inputSchemaRef` / `openInputSchemaRef`. + * + * @category Canvas Limits + */ +export const CANVAS_SCHEMA_MAX_PROPERTIES = 64; + +/** + * Maximum nesting depth of an inline JSON Schema + * (`CanvasActionDeclaration.inputSchema` / + * `CanvasTypeDeclaration.openInputSchema`), counting the root object as + * depth `1`. A schema that would exceed this MUST instead be represented + * via `inputSchemaRef` / `openInputSchemaRef`. + * + * @category Canvas Limits + */ +export const CANVAS_SCHEMA_MAX_DEPTH = 4; + +/** + * Maximum declared actions per canvas — + * {@link CanvasReadyAvailabilityState.actions} and + * {@link CanvasTypeDeclaration.declaredActions}. Hosts MUST NOT declare more + * than this; a provider with a larger action surface MUST group or page + * actions out of band rather than exceed this bound. + * + * @category Canvas Limits + */ +export const CANVAS_MAX_DECLARED_ACTIONS = 64; + +/** + * Maximum UTF-16 code units of the JSON-serialized `input` for `openCanvas` + * or `invokeCanvasAction`. Hosts MUST reject a larger `input` with + * `InvalidParams` (`-32602`). + * + * @category Canvas Limits + */ +export const CANVAS_INPUT_MAX_LENGTH = 65536; + +/** + * Maximum UTF-16 code units of the JSON-serialized `result` returned by + * `invokeCanvasAction`. A provider reply that would exceed this MUST be + * represented out of band (e.g. a resource the client resolves separately) + * rather than returned inline — large results are bounded/lazy references, + * never persisted session-summary metadata. + * + * @category Canvas Limits + */ +export const CANVAS_RESULT_MAX_LENGTH = 65536; + +/** + * Returns whether an inline JSON Schema object satisfies + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} and {@link CANVAS_SCHEMA_MAX_DEPTH}. + * Hosts MUST reject (or represent via a `*Ref` field instead of inlining) + * any schema for which this returns `false`. + * + * Only walks `properties`-shaped nesting (recursing into any property value + * that itself looks like a nested object schema, i.e. carries its own + * `properties`). A schema that manages to exceed the property or depth bound + * through some other JSON Schema construct (e.g. `$ref`, `items`, + * `oneOf`/`anyOf`) is out of scope for this helper and MUST still be + * rejected by a conformant host. + * + * @category Canvas Limits + */ +export function isCanvasSchemaWithinLimits( + schema: { readonly properties?: Record }, + depth = 1, +): boolean { + const props = schema.properties; + if (!props) { + return true; + } + if (Object.keys(props).length > CANVAS_SCHEMA_MAX_PROPERTIES) { + return false; + } + for (const value of Object.values(props)) { + if (!isRecord(value)) { + continue; + } + const nestedProperties = value.properties; + if (!isRecord(nestedProperties)) { + continue; + } + if (depth >= CANVAS_SCHEMA_MAX_DEPTH) { + return false; + } + if (!isCanvasSchemaWithinLimits({ properties: nestedProperties }, depth + 1)) { + return false; + } + } + return true; +} + +/** Type predicate narrowing an arbitrary schema-property value to a plain object, so `.properties` can be read without a type assertion. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +// ─── Trust ─────────────────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasTrustState} — whether the host currently + * permits this canvas's declared actions to execute. + * + * Trust is independent of {@link CanvasAvailabilityStatus | availability}: + * a canvas may be perfectly capable of rendering while blocked from + * executing actions, and vice versa. Trust decisions are host/runtime + * authority, not something this protocol grants. + * + * @category Canvas Trust + * @nonexhaustive + */ +export const enum CanvasTrustStatus { + /** Declared actions may be invoked. */ + Trusted = 'trusted', + /** A trust decision has not yet been made (e.g. first use of a new/changed source). */ + Pending = 'pending', + /** The host has denied execution; declared actions MUST NOT be invoked. */ + Blocked = 'blocked', +} + +/** @category Canvas Trust */ +export interface CanvasTrustedState { + status: CanvasTrustStatus.Trusted; +} + +/** @category Canvas Trust */ +export interface CanvasPendingTrustState { + status: CanvasTrustStatus.Pending; +} + +/** @category Canvas Trust */ +export interface CanvasBlockedTrustState { + status: CanvasTrustStatus.Blocked; + /** Optional human-readable reason surfaced to the user. */ + reason?: string; +} + +/** + * Current trust decision governing whether a canvas's declared actions may + * execute. + * + * @category Canvas Trust + */ +export type CanvasTrustState = + | CanvasTrustedState + | CanvasPendingTrustState + | CanvasBlockedTrustState; + +// ─── Declared Actions ──────────────────────────────────────────────────────── + +/** + * One action a canvas declares it can perform, invoked via + * `invokeCanvasAction`. + * + * Declarations are carried only on the full {@link CanvasState}, loaded when + * a client subscribes — never duplicated into the lightweight + * {@link CanvasEntry} catalog entry, keeping session summaries small. + * + * @category Canvas Actions + */ +export interface CanvasActionDeclaration { + /** Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. */ + id: string; + /** Human-readable display name. */ + title?: string; + /** Description of what invoking the action does. */ + description?: string; + /** + * Inline JSON Schema for the expected `input`, when small enough to embed + * (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + * checked by {@link isCanvasSchemaWithinLimits}). Optional because some + * declared actions take no input. Mutually exclusive with + * `inputSchemaRef` — a declaration MUST supply at most one of the two. + */ + inputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** + * Bounded out-of-band reference to a larger JSON Schema, used instead of + * `inputSchema` when the schema would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. AHP does not mandate a specific resolution mechanism for this + * URI (e.g. a host MAY make it `resourceRead`-able). + */ + inputSchemaRef?: URI; +} + +/** + * A canvas type an installed extension or package currently makes available + * to open for a chat, as returned by `listCanvasTypes`. + * + * `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is + * unrelated to {@link CanvasEntry}, which represents durable membership of + * an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the + * catalogue (via `listCanvasTypes`) MUST NOT execute or start a provider, + * or open, materialize, or admit a canvas. Membership requires `openCanvas` + * or host publication of a correlated, already-open native instance under + * that command's admission rules. + * + * @category Canvas State + */ +export interface CanvasTypeDeclaration { + /** The extension or package that declares this canvas type. */ + source: CanvasSource; + /** + * Provider-declared canvas type (host/provider-defined format), passed as + * {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + canvasType: string; + /** Human-readable display name for a canvas-type picker. */ + title: string; + /** Description of what this canvas type does. */ + description?: string; + /** Optional display icon. */ + icon?: Icon; + /** + * Inline JSON Schema describing the `openCanvas` `input` this type + * expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + * / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + * `openInputSchemaRef`. + */ + openInputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** + * Bounded out-of-band reference to a larger open-input JSON Schema, used + * instead of `openInputSchema` when it would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. + */ + openInputSchemaRef?: URI; + /** + * Advisory, statically-known preview of actions this canvas type + * typically declares once opened (bounded to + * {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + * the actual invocable actions for an opened instance are always + * {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + * depend on live provider configuration) and MUST be used instead of this + * preview once the canvas is open. + */ + declaredActions?: CanvasActionDeclaration[]; +} + +/** + * Transient, renderer-neutral presentation of a canvas's current live + * endpoint, returned by `resolveCanvasSource`. + * + * This is a plain URL, not any renderer- or process-model-specific handle + * (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview + * panel reference) — how a client actually presents it (a VS Code Webview, + * the Integrated Browser, or otherwise) is entirely a client/host + * implementation detail outside this protocol. + * + * @category Canvas State + */ +export interface CanvasSourcePresentation { + /** + * Ephemeral URL to the canvas's current live endpoint. Transient: MUST + * NOT be persisted (including durable canvas/session state or editor + * restoration data), written to routine logs, or treated as a stable + * identity. A host MAY embed + * short-lived, single-use credentials in it; such credentials are never + * durable authority. Renewed credentials MAY produce a different URL for + * the same incarnation and revision. Reuse is safe only while the + * credential is known to remain valid and reusable. + */ + url: string; + /** + * Advisory expiry hint for `url` (and any embedded credential), when + * known. Omission does not imply indefinite validity or reusability, and + * an unexpired credential may still be single-use. + */ + expiresAt?: string; +} + +// ─── Availability ──────────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasAvailabilityState} — the canvas's current + * live resolution state, independent of its durable + * {@link CanvasEntry | membership} in a session's catalog. + * + * An empty catalog membership list is not itself a close, and a canvas may + * remain a recorded member while its live availability cycles through these + * states any number of times (e.g. across provider restarts). + * + * @category Canvas Availability + * @nonexhaustive + */ +export const enum CanvasAvailabilityStatus { + /** + * The connected client or host does not support this canvas type (e.g. + * the client omitted the `canvases` capability, or no local runtime can + * render this `canvasType`). Distinct from `blocked` trust, which is a + * policy decision rather than a capability gap. + */ + Unsupported = 'unsupported', + /** Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. */ + NotLoaded = 'notLoaded', + /** Currently resolving or (re)connecting to a live endpoint. */ + Loading = 'loading', + /** Live and reachable, but the provider has not yet produced content to render. */ + Empty = 'empty', + /** Live, reachable, and has declared its current actions. */ + Ready = 'ready', + /** The live endpoint failed to resolve, or resolution otherwise failed. */ + Failed = 'failed', +} + +/** @category Canvas Availability */ +export interface CanvasUnsupportedAvailabilityState { + status: CanvasAvailabilityStatus.Unsupported; +} + +/** @category Canvas Availability */ +export interface CanvasNotLoadedAvailabilityState { + status: CanvasAvailabilityStatus.NotLoaded; +} + +/** @category Canvas Availability */ +export interface CanvasLoadingAvailabilityState { + status: CanvasAvailabilityStatus.Loading; +} + +/** @category Canvas Availability */ +export interface CanvasEmptyAvailabilityState { + status: CanvasAvailabilityStatus.Empty; +} + +/** + * @category Canvas Availability + */ +export interface CanvasReadyAvailabilityState { + status: CanvasAvailabilityStatus.Ready; + /** Actions currently declared by the live provider (full replacement each time this state is produced). */ + actions: CanvasActionDeclaration[]; +} + +/** @category Canvas Availability */ +export interface CanvasFailedAvailabilityState { + status: CanvasAvailabilityStatus.Failed; + /** Stable machine-readable and human-readable failure information. */ + error: ErrorInfo; +} + +/** + * Current live resolution state of a canvas. + * + * @category Canvas Availability + */ +export type CanvasAvailabilityState = + | CanvasUnsupportedAvailabilityState + | CanvasNotLoadedAvailabilityState + | CanvasLoadingAvailabilityState + | CanvasEmptyAvailabilityState + | CanvasReadyAvailabilityState + | CanvasFailedAvailabilityState; + +// ─── Catalog Entry ─────────────────────────────────────────────────────────── + +/** + * Lightweight catalog entry for a canvas, carried in + * {@link SessionState.canvases | `SessionState.canvases`}. Presence + * represents durable **logical membership** — it is unaffected by the live + * {@link CanvasEntry.availability | `availability`} cycling through + * `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. + * + * Membership is admitted by `openCanvas` or by host publication of a + * correlated, already-open native instance under that command's admission + * rules, never by discovery, subscription, or source resolution. + * + * The full state, including declared actions, lives in {@link CanvasState}, + * loaded when a client subscribes to {@link CanvasEntry.resource}. + * + * @category Canvas State + */ +export interface CanvasEntry { + /** Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. */ + resource: URI; + /** Full identity, including current incarnation. */ + identity: CanvasIdentity; + /** Human-readable display title. */ + title: string; + /** Optional display icon. */ + icon?: Icon; + /** Current trust decision matching {@link CanvasState.trust}. */ + trust: CanvasTrustState; + /** Current availability status matching {@link CanvasState.availability}'s discriminant. */ + availability: CanvasAvailabilityStatus; + /** + * Monotonically increasing counter bumped on every change to this + * canvas's state (trust, availability, or incarnation). Clients MAY use it + * to detect and reject stale reads without a full deep comparison. + * Transient presentation credential renewal alone does not require a + * revision change. + */ + revision: number; + /** Opaque host-defined summary metadata. */ + _meta?: Record; +} + +/** + * Full state for a single canvas, loaded when a client subscribes to the + * canvas's URI. + * + * `CanvasState` **denormalizes** every {@link CanvasEntry} field directly + * onto itself, replacing `availability`'s lightweight status with the full + * {@link CanvasAvailabilityState} (including declared actions or failure + * detail). Producers MUST keep the two representations consistent: any + * change to the inlined fields SHOULD also be announced on the owning + * session via {@link SessionCanvasSetAction | `session/canvasSet`}. + * + * @category Canvas State + */ +export interface CanvasState { + /** URI of this canvas channel. */ + resource: URI; + /** Full identity, including current incarnation. */ + identity: CanvasIdentity; + /** Human-readable display title. */ + title: string; + /** Optional display icon. */ + icon?: Icon; + /** Current trust decision. */ + trust: CanvasTrustState; + /** Current live resolution state. */ + availability: CanvasAvailabilityState; + /** Matches {@link CanvasEntry.revision}. */ + revision: number; + /** Opaque host-defined metadata. */ + _meta?: Record; +} diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 4edf17b3..e496ccb0 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -17,6 +17,7 @@ import type { import type { URI } from '../common/state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { ChatSummary } from '../channels-chat/state.js'; +import type { CanvasEntry } from '../channels-canvas/state.js'; // ─── Session Actions ───────────────────────────────────────────────────────── @@ -192,6 +193,47 @@ export interface SessionChangesetsChangedAction { changesets: Changeset[] | undefined; } +/** + * A canvas was admitted (opened) or its catalog entry changed. + * + * Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the + * server dispatches this with the full entry to record a newly opened + * canvas, or to republish it after a trust/availability/incarnation change + * so subscribers following only the session channel stay in sync with + * {@link CanvasState}. Never client-dispatchable: admission is through + * `openCanvas` or host publication of a correlated, already-open native + * instance under that command's admission rules. Both paths MUST use the + * same singular identity-to-resource binding; repeated native observations + * MUST NOT create a second entry. A stale/out-of-order delivery + * (`canvas.revision` not strictly greater than the currently-recorded entry's + * revision) MUST be rejected (no-op) rather than overwrite a newer entry with + * older data. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCanvasSetAction { + type: ActionType.SessionCanvasSet; + /** The canvas entry to add or update, matched by `resource`. */ + canvas: CanvasEntry; +} + +/** + * A canvas was logically closed. + * + * Remove semantics keyed by `resource`: an unknown URI is a no-op. This + * represents durable membership removal, not a client hiding a local + * tab/view — see `closeCanvas`. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCanvasRemovedAction { + type: ActionType.SessionCanvasRemoved; + /** Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. */ + resource: URI; +} + /** * Server tools for this session have changed. * diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index b497db3e..096c5baa 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -223,6 +223,36 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: : stateWithoutChangesets; } + case ActionType.SessionCanvasSet: { + const list = state.canvases ?? []; + const idx = list.findIndex(c => c.resource === action.canvas.resource); + if (idx < 0) { + return { ...state, canvases: [...list, action.canvas] }; + } + // Reject a stale/out-of-order membership update rather than let it + // overwrite a newer catalog entry with older data. + if (action.canvas.revision <= list[idx].revision) { + return state; + } + const updated = list.slice(); + updated[idx] = action.canvas; + return { ...state, canvases: updated }; + } + + case ActionType.SessionCanvasRemoved: { + const list = state.canvases; + if (!list) { + return state; + } + const idx = list.findIndex(c => c.resource === action.resource); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, canvases: updated }; + } + case ActionType.SessionConfigChanged: if (!state.config) { return state; diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index 78db1000..754c5528 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -7,6 +7,7 @@ */ import type { Changeset } from '../channels-changeset/state.js'; +import type { CanvasEntry } from '../channels-canvas/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; import type { ChatSummary, @@ -219,6 +220,17 @@ export interface SessionState extends SessionMetadata { * {@link /guide/changesets | Changesets} for an overview of the model. */ changesets?: Changeset[]; + /** + * Catalog of canvases opened for chats in this session. Presence is + * durable logical membership, admitted via `openCanvas` or host publication + * of a correlated, already-open native instance under that command's + * admission rules. Membership is never implied by discovery, subscription, + * source resolution, a chat's existence, or a client's earlier focus. + * Each entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the + * exact backing chat; a canvas never migrates to a different chat. See + * {@link CanvasEntry} for the full membership/availability/trust model. + */ + canvases?: CanvasEntry[]; /** * Outstanding input the session is blocked on, aggregated across every chat * so a client can discover and answer it from the session channel alone, diff --git a/types/commands.ts b/types/commands.ts index 700bbfa0..4470f3cf 100644 --- a/types/commands.ts +++ b/types/commands.ts @@ -15,3 +15,4 @@ export * from './channels-terminal/commands.js'; export * from './channels-changeset/commands.js'; export * from './channels-resource-watch/commands.js'; export * from './channels-automation/commands.js'; +export * from './channels-canvas/commands.js'; diff --git a/types/common/actions.ts b/types/common/actions.ts index 99cc4276..6e23a00b 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -44,6 +44,8 @@ import type { SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction, + SessionCanvasSetAction, + SessionCanvasRemovedAction, } from '../channels-session/actions.js'; import type { @@ -128,6 +130,13 @@ import type { AutomationRunPrimarySessionChangedAction, AutomationRunCancelRequestedAction, } from '../channels-automation-run/actions.js'; +import type { + CanvasAvailabilityChangedAction, + CanvasTrustChangedAction, + CanvasIncarnationChangedAction, + CanvasTitleChangedAction, + CanvasIconChangedAction, +} from '../channels-canvas/actions.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -234,6 +243,13 @@ export const enum ActionType { AutomationRunSessionRemoved = 'automationRun/sessionRemoved', AutomationRunPrimarySessionChanged = 'automationRun/primarySessionChanged', AutomationRunCancelRequested = 'automationRun/cancelRequested', + SessionCanvasSet = 'session/canvasSet', + SessionCanvasRemoved = 'session/canvasRemoved', + CanvasAvailabilityChanged = 'canvas/availabilityChanged', + CanvasTrustChanged = 'canvas/trustChanged', + CanvasIncarnationChanged = 'canvas/incarnationChanged', + CanvasTitleChanged = 'canvas/titleChanged', + CanvasIconChanged = 'canvas/iconChanged', } // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -302,6 +318,8 @@ export type StateAction = | SessionChangesetsChangedAction | SessionConfigChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction | ChatTurnStartedAction | ChatDeltaAction | ChatResponsePartAction @@ -365,4 +383,9 @@ export type StateAction = | AutomationRunSessionSetAction | AutomationRunSessionRemovedAction | AutomationRunPrimarySessionChangedAction - | AutomationRunCancelRequestedAction; + | AutomationRunCancelRequestedAction + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction + | CanvasIconChangedAction; diff --git a/types/common/commands.ts b/types/common/commands.ts index d2bff1e8..f93bbdd4 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -219,6 +219,25 @@ export interface ClientCapabilities { * App-bearing tool calls as ordinary MCP tool calls. */ mcpApps?: Record; + /** + * Client can render local canvases: `listCanvasTypes`, `openCanvas`, + * subscribe to the resulting `ahp-canvas:` channel, and drive + * `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + * `closeCanvas`. + * + * Hosts SHOULD NOT offer canvas admission to a client that omits this + * capability; such a client MUST be treated as if every canvas were + * {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + * anything about server/runtime execution trust — see + * {@link CanvasTrustStatus}, which is a separate, host-owned decision. + * + * This declares only the CLIENT's rendering capability. Protocol version + * support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + * actually has a working canvas runtime — see + * {@link InitializeResult.canvases}, the server-side counterpart, which a + * client MUST also check before treating canvases as usable. + */ + canvases?: Record; } /** @@ -293,8 +312,35 @@ export interface InitializeResult { * @see {@link /guide/automations | Automations Guide} */ automations?: AutomationCapabilities; + /** + * Host/runtime-owned local-canvas support. Presence means the SERVER + * currently has a working runtime able to serve `openCanvas` / + * `invokeCanvasAction` for at least one qualifying (explicitly installed + * and trust-eligible) extension/package source; absence means the host + * has no available canvas runtime, and clients MUST treat every canvas as + * {@link CanvasAvailabilityStatus.Unsupported} regardless of what + * {@link ClientCapabilities.canvases} declared. + * + * **Protocol version support alone is not a runtime capability**: a host + * speaking protocol `>= 0.10.0` without this field present MUST NOT be + * assumed to have a usable canvas runtime. This field — not the + * negotiated `protocolVersion` — is the authoritative signal, and is + * independent of any individual canvas's live availability + * ({@link CanvasAvailabilityState}) or trust decision + * ({@link CanvasTrustState}). + */ + canvases?: CanvasCapabilities; } +/** + * Local-canvas runtime features supported by this host authority. The empty + * object means "supported" — see {@link InitializeResult.canvases} for what + * presence/absence of this field itself means. + * + * @category Commands + */ +export interface CanvasCapabilities {} + /** * Automation features supported by this host authority. * diff --git a/types/common/messages.ts b/types/common/messages.ts index 4ffe0987..2d0bb1f9 100644 --- a/types/common/messages.ts +++ b/types/common/messages.ts @@ -78,6 +78,18 @@ import type { FetchAutomationRunsParams, FetchAutomationRunsResult, } from '../channels-automation/commands.js'; +import type { + ListCanvasTypesParams, + ListCanvasTypesResult, + OpenCanvasParams, + OpenCanvasResult, + ResolveCanvasSourceParams, + ResolveCanvasSourceResult, + InvokeCanvasActionParams, + InvokeCanvasActionResult, + RestartCanvasProviderParams, + CloseCanvasParams, +} from '../channels-canvas/commands.js'; import type { ActionEnvelope } from './actions.js'; import type { @@ -185,6 +197,12 @@ export interface CommandMap { 'listAutomationTriggerDefinitions': { params: ListAutomationTriggerDefinitionsParams; result: ListAutomationTriggerDefinitionsResult }; 'runAutomation': { params: RunAutomationParams; result: RunAutomationResult }; 'fetchAutomationRuns': { params: FetchAutomationRunsParams; result: FetchAutomationRunsResult }; + 'listCanvasTypes': { params: ListCanvasTypesParams; result: ListCanvasTypesResult }; + 'openCanvas': { params: OpenCanvasParams; result: OpenCanvasResult }; + 'resolveCanvasSource': { params: ResolveCanvasSourceParams; result: ResolveCanvasSourceResult }; + 'invokeCanvasAction': { params: InvokeCanvasActionParams; result: InvokeCanvasActionResult }; + 'restartCanvasProvider': { params: RestartCanvasProviderParams; result: null }; + 'closeCanvas': { params: CloseCanvasParams; result: null }; } /** diff --git a/types/common/reducer-helpers.ts b/types/common/reducer-helpers.ts index 02a9d46e..61c73ba6 100644 --- a/types/common/reducer-helpers.ts +++ b/types/common/reducer-helpers.ts @@ -20,6 +20,8 @@ import type { ClientAutomationAction, AutomationRunAction, ClientAutomationRunAction, + CanvasAction, + ClientCanvasAction, } from '../action-origin.generated.js'; import { IS_CLIENT_DISPATCHABLE } from '../action-origin.generated.js'; @@ -44,6 +46,6 @@ export function softAssertNever(value: never, log?: (msg: string) => void): void * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction { +export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction | CanvasAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | ClientCanvasAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/types/common/state.ts b/types/common/state.ts index b56048c6..17dae020 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -16,6 +16,7 @@ import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; import type { AutomationState } from '../channels-automation/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; +import type { CanvasState } from '../channels-canvas/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -335,7 +336,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/types/index.ts b/types/index.ts index 048e196f..ca0a4c6c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -31,5 +31,6 @@ export { resourceWatchReducer, automationReducer, automationRunReducer, + canvasReducer, isClientDispatchable, } from './reducers.js'; diff --git a/types/messages.test.ts b/types/messages.test.ts index 7abe8b02..3e5d9d1c 100644 --- a/types/messages.test.ts +++ b/types/messages.test.ts @@ -35,6 +35,7 @@ function readChannelSources(baseName: string): string { 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-canvas', ]; return dirs .map(dir => { diff --git a/types/reducers.test.ts b/types/reducers.test.ts index cc90200a..474f9a8f 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -27,11 +27,12 @@ import { resourceWatchReducer, automationReducer, automationRunReducer, + canvasReducer, isClientDispatchable, } from './reducers.js'; import { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; import { ActionType } from './actions.js'; -import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState } from './state.js'; +import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState, CanvasState } from './state.js'; import { SessionStatus, TurnState, @@ -58,6 +59,7 @@ function readChannelSources(baseName: string): string { 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-canvas', ]; return dirs .map(dir => { @@ -73,11 +75,11 @@ function readChannelSources(baseName: string): string { // ─── Fixture Loading ───────────────────────────────────────────────────────── -type FixtureState = RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState; +type FixtureState = RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState | CanvasState; interface Fixture { description: string; - reducer: 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; + reducer: 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun' | 'canvas'; initial: FixtureState; actions: unknown[]; expected: FixtureState; @@ -105,7 +107,13 @@ const fixtureFiles = readdirSync(fixtureDir).filter(f => f.endsWith('.json')).so const fixtures: Fixture[] = fixtureFiles.map(f => { const raw = JSON.parse(readFileSync(resolve(fixtureDir, f), 'utf-8')); - return nullToUndefined(raw) as Fixture; + return { + ...raw, + initial: nullToUndefined(raw.initial), + actions: raw.actions.map((action: { type?: string }) => + action.type === ActionType.CanvasIconChanged ? action : nullToUndefined(action)), + expected: nullToUndefined(raw.expected), + } as Fixture; }); // ─── Fixture-Driven Reducer Tests ──────────────────────────────────────────── @@ -131,6 +139,8 @@ describe('reducer fixtures', () => { state = automationReducer(state as AutomationState, action as any); } else if (fixture.reducer === 'automationRun') { state = automationRunReducer(state as AutomationRunState, action as any); + } else if (fixture.reducer === 'canvas') { + state = canvasReducer(state as CanvasState, action as any); } else { state = sessionReducer(state as SessionState, action as any); } @@ -210,6 +220,23 @@ describe('isClientDispatchable', () => { const action = { type: ActionType.SessionReady, session: 'x' } as const; assert.equal(isClientDispatchable(action), false); }); + + // Regression: canvas live-state and session-canvas-membership actions + // reflect authoritative host resolution outcomes (identity, trust, + // availability, membership), not client-optimistic writes — they MUST + // remain server-only. A client attempting to dispatch one of these + // directly would be forging state the host alone is authoritative for. + it('returns false for every canvas-scoped action (server-only)', () => { + assert.equal(isClientDispatchable({ type: ActionType.CanvasAvailabilityChanged, availability: { status: 'notLoaded' }, revision: 1 } as const), false); + assert.equal(isClientDispatchable({ type: ActionType.CanvasTrustChanged, trust: { status: 'pending' }, revision: 1 } as const), false); + assert.equal(isClientDispatchable({ type: ActionType.CanvasIncarnationChanged, incarnation: 'gen-1', revision: 1 } as const), false); + assert.equal(isClientDispatchable({ type: ActionType.CanvasTitleChanged, title: 'x', revision: 1 } as const), false); + assert.equal(isClientDispatchable({ type: ActionType.CanvasIconChanged, icon: null, revision: 1 } as const), false); + }); + + it('returns false for session canvas-membership actions (server-only)', () => { + assert.equal(isClientDispatchable({ type: ActionType.SessionCanvasRemoved, resource: 'ahp-canvas:/c1' } as const), false); + }); }); // ─── Immutability Checks ───────────────────────────────────────────────────── diff --git a/types/reducers.ts b/types/reducers.ts index f6cbdb31..b4b711e2 100644 --- a/types/reducers.ts +++ b/types/reducers.ts @@ -14,4 +14,5 @@ export { annotationsReducer } from './channels-annotations/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/reducer.js'; export { automationReducer } from './channels-automation/reducer.js'; export { automationRunReducer } from './channels-automation-run/reducer.js'; +export { canvasReducer } from './channels-canvas/reducer.js'; export { softAssertNever, isClientDispatchable } from './common/reducer-helpers.js'; diff --git a/types/state.ts b/types/state.ts index 28b13bfd..8346ffe7 100644 --- a/types/state.ts +++ b/types/state.ts @@ -18,3 +18,4 @@ export * from './channels-otlp/state.js'; export * from './channels-resource-watch/state.js'; export * from './channels-automation/state.js'; export * from './channels-automation-run/state.js'; +export * from './channels-canvas/state.js'; diff --git a/types/test-cases/reducers/280-canvas-availabilitychanged-updates-state-and-bumps-revision.json b/types/test-cases/reducers/280-canvas-availabilitychanged-updates-state-and-bumps-revision.json new file mode 100644 index 00000000..24f6c7b4 --- /dev/null +++ b/types/test-cases/reducers/280-canvas-availabilitychanged-updates-state-and-bumps-revision.json @@ -0,0 +1,39 @@ +{ + "description": "canvas/availabilityChanged replaces availability and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "notLoaded" }, + "revision": 1 + }, + "actions": [ + { + "type": "canvas/availabilityChanged", + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } +} diff --git a/types/test-cases/reducers/281-canvas-trustchanged-blocks-and-bumps-revision.json b/types/test-cases/reducers/281-canvas-trustchanged-blocks-and-bumps-revision.json new file mode 100644 index 00000000..1944aba7 --- /dev/null +++ b/types/test-cases/reducers/281-canvas-trustchanged-blocks-and-bumps-revision.json @@ -0,0 +1,35 @@ +{ + "description": "canvas/trustChanged replaces trust and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "package", "sourceId": "pkg-contoso-widgets-workspace", "packageName": "contoso-widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": { "status": "notLoaded" }, + "revision": 1 + }, + "actions": [ + { "type": "canvas/trustChanged", "trust": { "status": "blocked", "reason": "unreviewed package" }, "revision": 2 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "package", "sourceId": "pkg-contoso-widgets-workspace", "packageName": "contoso-widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "blocked", "reason": "unreviewed package" }, + "availability": { "status": "notLoaded" }, + "revision": 2 + } +} diff --git a/types/test-cases/reducers/282-canvas-incarnationchanged-bumps-incarnation-and-revision.json b/types/test-cases/reducers/282-canvas-incarnationchanged-bumps-incarnation-and-revision.json new file mode 100644 index 00000000..07d2dcbe --- /dev/null +++ b/types/test-cases/reducers/282-canvas-incarnationchanged-bumps-incarnation-and-revision.json @@ -0,0 +1,35 @@ +{ + "description": "canvas/incarnationChanged replaces the opaque incarnation token and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 4 + }, + "actions": [ + { "type": "canvas/incarnationChanged", "incarnation": "gen-2", "revision": 5 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 5 + } +} diff --git a/types/test-cases/reducers/283-canvas-titlechanged-updates-title-and-bumps-revision.json b/types/test-cases/reducers/283-canvas-titlechanged-updates-title-and-bumps-revision.json new file mode 100644 index 00000000..e490b69b --- /dev/null +++ b/types/test-cases/reducers/283-canvas-titlechanged-updates-title-and-bumps-revision.json @@ -0,0 +1,35 @@ +{ + "description": "canvas/titleChanged replaces title and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 1 + }, + "actions": [ + { "type": "canvas/titleChanged", "title": "Renamed Widget", "revision": 2 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Renamed Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 2 + } +} diff --git a/types/test-cases/reducers/284-canvas-unknown-action-type-is-no-op.json b/types/test-cases/reducers/284-canvas-unknown-action-type-is-no-op.json new file mode 100644 index 00000000..184fc0a6 --- /dev/null +++ b/types/test-cases/reducers/284-canvas-unknown-action-type-is-no-op.json @@ -0,0 +1,35 @@ +{ + "description": "canvas unknown action type is a no-op", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 1 + }, + "actions": [ + { "type": "canvas/unknownAction" } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 1 + } +} diff --git a/types/test-cases/reducers/285-session-canvasset-appends-new-canvas.json b/types/test-cases/reducers/285-session-canvasset-appends-new-canvas.json new file mode 100644 index 00000000..aa5d4df4 --- /dev/null +++ b/types/test-cases/reducers/285-session-canvasset-appends-new-canvas.json @@ -0,0 +1,55 @@ +{ + "description": "session/canvasSet appends a new canvas entry when its resource is unknown", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": "notLoaded", + "revision": 1 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": "notLoaded", + "revision": 1 + } + ] + } +} diff --git a/types/test-cases/reducers/286-session-canvasset-replaces-existing-canvas.json b/types/test-cases/reducers/286-session-canvasset-replaces-existing-canvas.json new file mode 100644 index 00000000..f157c600 --- /dev/null +++ b/types/test-cases/reducers/286-session-canvasset-replaces-existing-canvas.json @@ -0,0 +1,71 @@ +{ + "description": "session/canvasSet replaces an existing canvas entry matched by resource", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "pending" }, + "availability": "notLoaded", + "revision": 1 + } + ] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + ] + } +} diff --git a/types/test-cases/reducers/287-session-canvasremoved-removes-matching-and-noop-unknown.json b/types/test-cases/reducers/287-session-canvasremoved-removes-matching-and-noop-unknown.json new file mode 100644 index 00000000..556f1518 --- /dev/null +++ b/types/test-cases/reducers/287-session-canvasremoved-removes-matching-and-noop-unknown.json @@ -0,0 +1,41 @@ +{ + "description": "session/canvasRemoved removes the matching canvas entry and is a no-op for an unknown resource", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + ] + }, + "actions": [ + { "type": "session/canvasRemoved", "resource": "ahp-canvas:/unknown" }, + { "type": "session/canvasRemoved", "resource": "ahp-canvas:/c1" } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [] + } +} diff --git a/types/test-cases/reducers/288-session-canvasremoved-noop-without-canvases-list.json b/types/test-cases/reducers/288-session-canvasremoved-noop-without-canvases-list.json new file mode 100644 index 00000000..5b227c5d --- /dev/null +++ b/types/test-cases/reducers/288-session-canvasremoved-noop-without-canvases-list.json @@ -0,0 +1,23 @@ +{ + "description": "session/canvasRemoved is a no-op when the session has no canvases catalogue at all", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { "type": "session/canvasRemoved", "resource": "ahp-canvas:/c1" } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + } +} diff --git a/types/test-cases/reducers/289-canvas-rejects-stale-revision-including-a-would-be-incarnation-regression.json b/types/test-cases/reducers/289-canvas-rejects-stale-revision-including-a-would-be-incarnation-regression.json new file mode 100644 index 00000000..4fb019ae --- /dev/null +++ b/types/test-cases/reducers/289-canvas-rejects-stale-revision-including-a-would-be-incarnation-regression.json @@ -0,0 +1,36 @@ +{ + "description": "canvas reducer rejects (no-ops) an action whose asserted revision is not strictly greater than the current revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 5 + }, + "actions": [ + { "type": "canvas/titleChanged", "title": "Stale duplicate title", "revision": 5 }, + { "type": "canvas/incarnationChanged", "incarnation": "gen-1", "revision": 3 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "ready", "actions": [] }, + "revision": 5 + } +} diff --git a/types/test-cases/reducers/290-session-canvasset-rejects-stale-revision.json b/types/test-cases/reducers/290-session-canvasset-rejects-stale-revision.json new file mode 100644 index 00000000..6c9111f4 --- /dev/null +++ b/types/test-cases/reducers/290-session-canvasset-rejects-stale-revision.json @@ -0,0 +1,71 @@ +{ + "description": "session/canvasSet rejects (no-ops) a stale/duplicate catalog update whose canvas.revision is not strictly greater than the currently-recorded entry's revision", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 5 + } + ] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Stale Widget Title", + "trust": { "status": "trusted" }, + "availability": "notLoaded", + "revision": 3 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-2" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 5 + } + ] + } +} diff --git a/types/test-cases/reducers/291-session-canvasset-replays-host-published-membership.json b/types/test-cases/reducers/291-session-canvasset-replays-host-published-membership.json new file mode 100644 index 00000000..15a011ca --- /dev/null +++ b/types/test-cases/reducers/291-session-canvasset-replays-host-published-membership.json @@ -0,0 +1,89 @@ +{ + "description": "session/canvasSet records a host-published live entry, ignores its replay, and updates the same resource without duplicating membership or granting trust", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Native Widget", + "trust": { "status": "pending" }, + "availability": "ready", + "revision": 1 + } + }, + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Native Widget", + "trust": { "status": "pending" }, + "availability": "ready", + "revision": 1 + } + }, + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Updated Native Widget", + "trust": { "status": "pending" }, + "availability": "ready", + "revision": 2 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Updated Native Widget", + "trust": { "status": "pending" }, + "availability": "ready", + "revision": 2 + } + ] + } +} diff --git a/types/test-cases/reducers/292-canvas-ready-preserves-pending-trust.json b/types/test-cases/reducers/292-canvas-ready-preserves-pending-trust.json new file mode 100644 index 00000000..3e09fb85 --- /dev/null +++ b/types/test-cases/reducers/292-canvas-ready-preserves-pending-trust.json @@ -0,0 +1,39 @@ +{ + "description": "canvas/availabilityChanged becoming ready preserves pending trust and the current incarnation", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Native Widget", + "trust": { "status": "pending" }, + "availability": { "status": "notLoaded" }, + "revision": 1 + }, + "actions": [ + { + "type": "canvas/availabilityChanged", + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } + ], + "expected": { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Native Widget", + "trust": { "status": "pending" }, + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } +} diff --git a/types/test-cases/reducers/293-canvas-ready-preserves-blocked-trust.json b/types/test-cases/reducers/293-canvas-ready-preserves-blocked-trust.json new file mode 100644 index 00000000..126c085b --- /dev/null +++ b/types/test-cases/reducers/293-canvas-ready-preserves-blocked-trust.json @@ -0,0 +1,39 @@ +{ + "description": "canvas/availabilityChanged becoming ready preserves blocked trust and the current incarnation", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Native Widget", + "trust": { "status": "blocked", "reason": "Execution denied by policy" }, + "availability": { "status": "empty" }, + "revision": 1 + }, + "actions": [ + { + "type": "canvas/availabilityChanged", + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } + ], + "expected": { + "resource": "ahp-canvas:/native", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "native-widget", + "incarnation": "native-endpoint-1" + }, + "title": "Native Widget", + "trust": { "status": "blocked", "reason": "Execution denied by policy" }, + "availability": { "status": "ready", "actions": [{ "id": "refresh", "title": "Refresh" }] }, + "revision": 2 + } +} diff --git a/types/test-cases/reducers/294-canvas-iconchanged-replaces-icon-and-bumps-revision.json b/types/test-cases/reducers/294-canvas-iconchanged-replaces-icon-and-bumps-revision.json new file mode 100644 index 00000000..a7e025ee --- /dev/null +++ b/types/test-cases/reducers/294-canvas-iconchanged-replaces-icon-and-bumps-revision.json @@ -0,0 +1,41 @@ +{ + "description": "canvas/iconChanged replaces the display icon and applies the action's asserted revision", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "icon": { "src": "https://example.com/old.png" }, + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 1 + }, + "actions": [ + { + "type": "canvas/iconChanged", + "icon": { "src": "https://example.com/new.png", "contentType": "image/png" }, + "revision": 2 + } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "icon": { "src": "https://example.com/new.png", "contentType": "image/png" }, + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 2 + } +} diff --git a/types/test-cases/reducers/295-canvas-iconchanged-null-removes-icon.json b/types/test-cases/reducers/295-canvas-iconchanged-null-removes-icon.json new file mode 100644 index 00000000..7a6ef4af --- /dev/null +++ b/types/test-cases/reducers/295-canvas-iconchanged-null-removes-icon.json @@ -0,0 +1,36 @@ +{ + "description": "canvas/iconChanged with null removes the optional display icon", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "icon": { "src": "https://example.com/widget.png" }, + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 2 + }, + "actions": [ + { "type": "canvas/iconChanged", "icon": null, "revision": 3 } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 3 + } +} diff --git a/types/test-cases/reducers/296-canvas-iconchanged-rejects-stale-revision.json b/types/test-cases/reducers/296-canvas-iconchanged-rejects-stale-revision.json new file mode 100644 index 00000000..60a8cc8c --- /dev/null +++ b/types/test-cases/reducers/296-canvas-iconchanged-rejects-stale-revision.json @@ -0,0 +1,41 @@ +{ + "description": "canvas/iconChanged rejects an icon replacement whose revision is not strictly greater", + "reducer": "canvas", + "initial": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "icon": { "src": "https://example.com/current.png" }, + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 5 + }, + "actions": [ + { + "type": "canvas/iconChanged", + "icon": { "src": "https://example.com/stale.png" }, + "revision": 5 + } + ], + "expected": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "icon": { "src": "https://example.com/current.png" }, + "trust": { "status": "trusted" }, + "availability": { "status": "empty" }, + "revision": 5 + } +} diff --git a/types/test-cases/reducers/297-session-canvasset-removes-stale-icon-from-catalogue-entry.json b/types/test-cases/reducers/297-session-canvasset-removes-stale-icon-from-catalogue-entry.json new file mode 100644 index 00000000..bae780aa --- /dev/null +++ b/types/test-cases/reducers/297-session-canvasset-removes-stale-icon-from-catalogue-entry.json @@ -0,0 +1,72 @@ +{ + "description": "session/canvasSet replaces a catalogue entry and removes an icon omitted by the authoritative snapshot", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "icon": { "src": "https://example.com/stale.png" }, + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 1 + } + ] + }, + "actions": [ + { + "type": "session/canvasSet", + "canvas": { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "canvases": [ + { + "resource": "ahp-canvas:/c1", + "identity": { + "chat": "ahp-chat:/s1/c1", + "source": { "kind": "extension", "extensionId": "contoso.widgets" }, + "canvasType": "contoso.widget", + "instanceId": "w1", + "incarnation": "gen-1" + }, + "title": "Widget", + "trust": { "status": "trusted" }, + "availability": "ready", + "revision": 2 + } + ] + } +} diff --git a/types/test-cases/round-trips/045-canvas-icon-changed-null.json b/types/test-cases/round-trips/045-canvas-icon-changed-null.json new file mode 100644 index 00000000..aa55a73c --- /dev/null +++ b/types/test-cases/round-trips/045-canvas-icon-changed-null.json @@ -0,0 +1,18 @@ +{ + "name": "canvas-icon-changed-null", + "group": "A", + "description": "canvas/iconChanged keeps its required icon field when the value is null so consumers can distinguish removal from an invalid omitted field.", + "type": "StateAction", + "input": { + "type": "canvas/iconChanged", + "icon": null, + "revision": 3 + }, + "acceptableOutputs": [ + { + "type": "canvas/iconChanged", + "icon": null, + "revision": 3 + } + ] +} diff --git a/types/version/message-checks.ts b/types/version/message-checks.ts index 3d6cc2c3..e94bd4a8 100644 --- a/types/version/message-checks.ts +++ b/types/version/message-checks.ts @@ -79,7 +79,13 @@ type _ExpectedCommands = | 'invokeChangesetOperation' | 'listAutomationTriggerDefinitions' | 'runAutomation' - | 'fetchAutomationRuns'; + | 'fetchAutomationRuns' + | 'listCanvasTypes' + | 'openCanvas' + | 'resolveCanvasSource' + | 'invokeCanvasAction' + | 'restartCanvasProvider' + | 'closeCanvas'; /** All methods annotated `@messageType Notification` (client → server). */ type _ExpectedClientNotifications = diff --git a/types/version/registry.ts b/types/version/registry.ts index ddddc86e..711ba8d3 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.9.0'; +export const PROTOCOL_VERSION = '0.10.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,6 +34,7 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -176,6 +177,13 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.AutomationRunSessionRemoved]: '0.8.0', [ActionType.AutomationRunPrimarySessionChanged]: '0.8.0', [ActionType.AutomationRunCancelRequested]: '0.8.0', + [ActionType.SessionCanvasSet]: '0.10.0', + [ActionType.SessionCanvasRemoved]: '0.10.0', + [ActionType.CanvasAvailabilityChanged]: '0.10.0', + [ActionType.CanvasTrustChanged]: '0.10.0', + [ActionType.CanvasIncarnationChanged]: '0.10.0', + [ActionType.CanvasTitleChanged]: '0.10.0', + [ActionType.CanvasIconChanged]: '0.10.0', }; /**