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