diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 0f5cbd13d..512cfbbb2 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -87,6 +87,10 @@ public ActionType(string value) public static readonly ActionType ChatCanvasesChanged = new ActionType("chat/canvasesChanged"); + public static readonly ActionType ChatCanvasRegistryRequested = new ActionType("chat/canvasRegistryRequested"); + + public static readonly ActionType ChatCanvasRegistryChanged = new ActionType("chat/canvasRegistryChanged"); + public static readonly ActionType CanvasStateChanged = new ActionType("canvas/stateChanged"); public static readonly ActionType ChatWorkingDirectorySet = new ActionType("chat/workingDirectorySet"); @@ -1847,6 +1851,39 @@ public sealed record ChatCanvasesChangedAction public List? Canvases { get; init; } } +/// Requests materialization of the chat's Canvas type registry. +/// +/// A host MUST reject the action when the chat's agent does not advertise +/// {@link AgentCapabilities.canvases} or when the chat cannot materialize the +/// registry. Materialization MUST NOT send a user message or invoke a model. +/// +/// The reducer is a no-op. The host remains authoritative and publishes the +/// complete result through +/// {@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}. +/// Clients do not need to dispatch this action when +/// {@link ChatState.canvasRegistry} is already present. +/// +/// Stability: 1.0 - Early development. +public sealed record ChatCanvasRegistryRequestedAction +{ + public ActionType Type { get; init; } = ActionType.ChatCanvasRegistryRequested; +} + +/// The Canvas type registry for this chat changed. +/// +/// Replaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely. +/// Set to `undefined` when the registry is no longer materialized or available. +/// +/// Stability: 1.0 - Early development. +public sealed record ChatCanvasRegistryChangedAction +{ + public ActionType Type { get; init; } = ActionType.ChatCanvasRegistryChanged; + + /// Authoritative registry, or `undefined` to clear it. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CanvasRegistry? CanvasRegistry { get; init; } +} + /// The presentation state for this canvas changed. /// /// Replaces the subscribed canvas channel state entirely. Full-replacement @@ -2922,6 +2959,8 @@ public StateActionConverter() ["chat/movableChanged"] = typeof(ChatMovableChangedAction), ["chat/changesetsChanged"] = typeof(ChatChangesetsChangedAction), ["chat/canvasesChanged"] = typeof(ChatCanvasesChangedAction), + ["chat/canvasRegistryRequested"] = typeof(ChatCanvasRegistryRequestedAction), + ["chat/canvasRegistryChanged"] = typeof(ChatCanvasRegistryChangedAction), ["canvas/stateChanged"] = typeof(CanvasStateChangedAction), ["chat/workingDirectorySet"] = typeof(ChatWorkingDirectorySetAction), ["chat/workingDirectoryRemoved"] = typeof(ChatWorkingDirectoryRemovedAction), diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 54c918b1f..47e854a32 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -1147,6 +1147,60 @@ public sealed record MoveChatResult public required string Session { get; init; } } +/// Opens one client-addressed canvas resource in a chat. +/// +/// The resulting live instance is published through the chat's +/// {@link ChatState.canvases} references and its `ahp-canvas:` state channel. +/// Repeating this command with the same `channel` is idempotent. Reusing it for +/// a different chat, extension, or canvas type MUST fail with `Conflict`. +/// Clients MUST observe +/// {@link ChatState.canvases} and the referenced `ahp-canvas:` channel for +/// authoritative membership, availability, and presentation state. +/// +/// Clients MUST NOT call this command unless the chat's agent advertises +/// {@link AgentCapabilities.canvases}. +public sealed record OpenCanvasParams +{ + /// Client-chosen URI for the canvas resource, for example + /// `ahp-canvas:/<uuid>`. + 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; } + + /// Exact chat URI that owns the canvas instance. + public required string Chat { get; init; } + + /// Stable identifier of the extension that declares the canvas. + public required string ExtensionId { get; init; } + + /// Provider-local canvas type identifier. + public required string CanvasId { get; init; } +} + +/// Closes and disposes one live canvas resource. +/// +/// The server removes the resource from the owning chat's +/// {@link ChatState.canvases} references. Repeating this command after the +/// resource is gone is a no-op. +/// +/// Clients MUST NOT call this command unless the chat's agent advertises +/// {@link AgentCapabilities.canvases}. +public sealed record DisposeCanvasParams +{ + /// URI of the canvas resource to dispose. + 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; } +} + /// Returns a list of session summaries. Used to populate session lists and sidebars. /// /// The session list is **not** part of the state tree because it can be arbitrarily diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index b9d54208f..85383305a 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -83,9 +83,13 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(BackgroundSubagentWork))] [JsonSerializable(typeof(BackgroundWork))] [JsonSerializable(typeof(BackgroundWorkKind))] +[JsonSerializable(typeof(CanvasesCapability))] +[JsonSerializable(typeof(CanvasExtension))] [JsonSerializable(typeof(CanvasReference))] +[JsonSerializable(typeof(CanvasRegistry))] [JsonSerializable(typeof(CanvasState))] [JsonSerializable(typeof(CanvasStateChangedAction))] +[JsonSerializable(typeof(CanvasType))] [JsonSerializable(typeof(Changeset))] [JsonSerializable(typeof(ChangesetCapabilities))] [JsonSerializable(typeof(ChangesetClearedAction))] @@ -120,6 +124,8 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ChatBackgroundWorkRemovedAction))] [JsonSerializable(typeof(ChatBackgroundWorkSetAction))] [JsonSerializable(typeof(ChatCanvasesChangedAction))] +[JsonSerializable(typeof(ChatCanvasRegistryChangedAction))] +[JsonSerializable(typeof(ChatCanvasRegistryRequestedAction))] [JsonSerializable(typeof(ChatChangesetsChangedAction))] [JsonSerializable(typeof(ChatConfigChangedAction))] [JsonSerializable(typeof(ChatConfigState))] @@ -228,6 +234,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(DirectoryCustomization))] [JsonSerializable(typeof(DirectoryEntry))] [JsonSerializable(typeof(DispatchActionParams))] +[JsonSerializable(typeof(DisposeCanvasParams))] [JsonSerializable(typeof(DisposeChatParams))] [JsonSerializable(typeof(DisposeSessionParams))] [JsonSerializable(typeof(DisposeTerminalParams))] @@ -290,6 +297,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(MultipleChatProvidersCapability))] [JsonSerializable(typeof(MultipleChatsCapability))] [JsonSerializable(typeof(MultipleWorkingDirectoriesCapability))] +[JsonSerializable(typeof(OpenCanvasParams))] [JsonSerializable(typeof(OtlpExportLogsParams))] [JsonSerializable(typeof(OtlpExportMetricsParams))] [JsonSerializable(typeof(OtlpExportTracesParams))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index 427076b09..c3e1d56ee 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -2053,6 +2053,22 @@ public sealed record AgentInfo /// per-capability options. public sealed record AgentCapabilities { + /// Experimental Canvas API. + /// + /// The agent can materialize an exact chat's + /// {@link ChatState.canvasRegistry | Canvas registry} through + /// `chat/canvasRegistryRequested` and open a declared type with `openCanvas`. + /// Live instances are published through the chat's canvas references and + /// their `ahp-canvas:` state channels. + /// + /// Clients MUST treat absence as unsupported and SHOULD tolerate additive + /// registry metadata while the Canvas channel remains experimental. This + /// capability does not gate server-published Canvas references. + /// + /// Stability: 1.0 - Early development. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CanvasesCapability? Canvases { get; init; } + /// The agent can host more than one concurrent chat per session. When absent, /// clients MUST NOT call `createChat` to open chats beyond the default one the /// session starts with. An empty object `{}` advertises multi-chat without @@ -2074,6 +2090,14 @@ public sealed record AgentCapabilities public MultipleWorkingDirectoriesCapability? MultipleWorkingDirectories { get; init; } } +/// Presence capability for the experimental Canvas registry and lifecycle +/// commands. +/// +/// Stability: 1.0 - Early development. +public sealed record CanvasesCapability +{ +} + /// Options for the {@link AgentCapabilities.multipleChats} capability. public sealed record MultipleChatsCapability { @@ -2545,6 +2569,19 @@ public sealed class ChatState [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? BackgroundWork { get; set; } + /// Canvas types currently registered for this exact chat. + /// + /// Absence means the registry has not been materialized or is not currently + /// available. Clients that need it MAY dispatch + /// `chat/canvasRegistryRequested`; the host publishes the authoritative + /// registry through `chat/canvasRegistryChanged`. An empty + /// {@link CanvasRegistry.extensions} array is a loaded registry with no + /// available canvas types. + /// + /// Stability: 1.0 - Early development. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CanvasRegistry? CanvasRegistry { get; set; } + /// Live canvases currently exposed by this chat. /// /// Entries intentionally contain only subscribable channel references. @@ -2606,6 +2643,46 @@ public sealed class ChatState public Dictionary? Meta { get; set; } } +/// One extension or provider that declares canvas types for a chat. +public sealed record CanvasExtension +{ + /// Stable opaque identifier. + public required string Id { get; init; } + + /// Human-readable label, when available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Label { get; init; } + + /// Canvas types declared by this extension/provider. `id` MUST be + /// unique within this array. + public required List Canvases { get; init; } +} + +/// One canvas type currently available to open. +public sealed record CanvasType +{ + /// Provider-local canvas type identifier. + public required string Id { get; init; } + + /// Human-readable canvas type label. + public required string Label { get; init; } + + /// Description of what the canvas type does. + public required string Description { get; init; } + + /// Whether opening this canvas requires an input flow. + public bool RequiresInput { get; init; } +} + +/// Canvas types currently registered for one chat. +public sealed record CanvasRegistry +{ + /// Extensions and providers that declare canvas types. `id` MUST be unique + /// within this array. An empty array is an authoritative loaded registry with + /// no types. + public required List Extensions { get; init; } +} + /// Stable reference to a subscribable canvas channel. /// /// Chat state intentionally carries only this reference so the experimental @@ -2624,13 +2701,17 @@ public sealed record CanvasReference /// redundantly carry its channel URI. public sealed class CanvasState { - /// Stable caller-supplied instance identifier. + /// Provider-facing instance identifier. + /// + /// AHP clients address this instance by the subscribed `ahp-canvas:` channel + /// URI. They MUST NOT treat this provider identifier as a second resource + /// identity. public required string InstanceId { get; set; } - /// Owning extension/provider identifier. + /// Owning declaration namespace. public required string ExtensionId { get; set; } - /// Owning extension display name, when available. + /// Owning namespace title, when available. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ExtensionName { get; set; } diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs index 1cc062b11..d7946852a 100644 --- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -94,6 +94,12 @@ public static bool TryGetActionType(object action, out ActionType actionType) case ChatCanvasesChangedAction value: actionType = value.Type; return true; + case ChatCanvasRegistryChangedAction value: + actionType = value.Type; + return true; + case ChatCanvasRegistryRequestedAction value: + actionType = value.Type; + return true; case ChatChangesetsChangedAction value: actionType = value.Type; return true; diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index 1f5ec3740..d0f5c8869 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -1054,6 +1054,11 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action) case ChatCanvasesChangedAction a: state.Canvases = CopyList(a.Canvases); return ReduceOutcome.Applied; + case ChatCanvasRegistryRequestedAction: + return ReduceOutcome.NoOp; + case ChatCanvasRegistryChangedAction a: + state.CanvasRegistry = a.CanvasRegistry; + return ReduceOutcome.Applied; case ChatWorkingDirectorySetAction a: { // Membership keyed by the directory URI, over this chat's subset of @@ -2640,6 +2645,7 @@ public static ReduceOutcome ApplyToAutomationRun( "chat/isReadChanged", "chat/isArchivedChanged", "chat/configChanged", + "chat/canvasRegistryRequested", "chat/inputAnswerChanged", "chat/inputCompleted", "chat/truncated", diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs index ad368af9e..63f351102 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs @@ -192,6 +192,10 @@ private static (object decoded, string reencoded) DecodeAndReencode(string type, return Wrap(Ser.Deserialize(inputJson)); case "ResourceReadResult": return Wrap(Ser.Deserialize(inputJson)); + case "OpenCanvasParams": + return Wrap(Ser.Deserialize(inputJson)); + case "DisposeCanvasParams": + return Wrap(Ser.Deserialize(inputJson)); default: throw new Xunit.Sdk.XunitException( $"round-trip fixture: unknown wire type \"{type}\". " diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 22fe67c01..f52bb86b7 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -647,6 +647,11 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R state.Canvases = append([]ahptypes.CanvasReference(nil), a.Canvases...) } return ReduceOutcomeApplied + case *ahptypes.ChatCanvasRegistryRequestedAction: + return ReduceOutcomeNoOp + case *ahptypes.ChatCanvasRegistryChangedAction: + state.CanvasRegistry = a.CanvasRegistry + return ReduceOutcomeApplied case *ahptypes.ChatWorkingDirectorySetAction: for _, d := range state.WorkingDirectories { if d == a.Directory { diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index b66c0dab7..6b3f06078 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -50,6 +50,8 @@ const ( ActionTypeChatMovableChanged ActionType = "chat/movableChanged" ActionTypeChatChangesetsChanged ActionType = "chat/changesetsChanged" ActionTypeChatCanvasesChanged ActionType = "chat/canvasesChanged" + ActionTypeChatCanvasRegistryRequested ActionType = "chat/canvasRegistryRequested" + ActionTypeChatCanvasRegistryChanged ActionType = "chat/canvasRegistryChanged" ActionTypeCanvasStateChanged ActionType = "canvas/stateChanged" ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet" ActionTypeChatWorkingDirectoryRemoved ActionType = "chat/workingDirectoryRemoved" @@ -721,6 +723,35 @@ type ChatCanvasesChangedAction struct { Canvases []CanvasReference `json:"canvases,omitempty"` } +// Requests materialization of the chat's Canvas type registry. +// +// A host MUST reject the action when the chat's agent does not advertise +// {@link AgentCapabilities.canvases} or when the chat cannot materialize the +// registry. Materialization MUST NOT send a user message or invoke a model. +// +// The reducer is a no-op. The host remains authoritative and publishes the +// complete result through +// {@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}. +// Clients do not need to dispatch this action when +// {@link ChatState.canvasRegistry} is already present. +// +// Stability: 1.0 - Early development. +type ChatCanvasRegistryRequestedAction struct { + Type ActionType `json:"type"` +} + +// The Canvas type registry for this chat changed. +// +// Replaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely. +// Set to `undefined` when the registry is no longer materialized or available. +// +// Stability: 1.0 - Early development. +type ChatCanvasRegistryChangedAction struct { + Type ActionType `json:"type"` + // Authoritative registry, or `undefined` to clear it. + CanvasRegistry *CanvasRegistry `json:"canvasRegistry,omitempty"` +} + // The presentation state for this canvas changed. // // Replaces the subscribed canvas channel state entirely. Full-replacement @@ -1883,6 +1914,8 @@ func (*ChatBackgroundWorkRemovedAction) isStateAction() {} func (*ChatMovableChangedAction) isStateAction() {} func (*ChatChangesetsChangedAction) isStateAction() {} func (*ChatCanvasesChangedAction) isStateAction() {} +func (*ChatCanvasRegistryRequestedAction) isStateAction() {} +func (*ChatCanvasRegistryChangedAction) isStateAction() {} func (*CanvasStateChangedAction) isStateAction() {} func (*SessionTitleChangedAction) isStateAction() {} func (*ChatUsageAction) isStateAction() {} @@ -2166,6 +2199,18 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/canvasRegistryRequested": + var value ChatCanvasRegistryRequestedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "chat/canvasRegistryChanged": + var value ChatCanvasRegistryChangedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "canvas/stateChanged": var value CanvasStateChangedAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 332dc0027..dd2b55181 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -764,6 +764,48 @@ type MoveChatResult struct { Session URI `json:"session"` } +// Opens one client-addressed canvas resource in a chat. +// +// The resulting live instance is published through the chat's +// {@link ChatState.canvases} references and its `ahp-canvas:` state channel. +// Repeating this command with the same `channel` is idempotent. Reusing it for +// a different chat, extension, or canvas type MUST fail with `Conflict`. +// Clients MUST observe +// {@link ChatState.canvases} and the referenced `ahp-canvas:` channel for +// authoritative membership, availability, and presentation state. +// +// Clients MUST NOT call this command unless the chat's agent advertises +// {@link AgentCapabilities.canvases}. +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"` + // Exact chat URI that owns the canvas instance. + Chat URI `json:"chat"` + // Stable identifier of the extension that declares the canvas. + ExtensionId string `json:"extensionId"` + // Provider-local canvas type identifier. + CanvasId string `json:"canvasId"` +} + +// Closes and disposes one live canvas resource. +// +// The server removes the resource from the owning chat's +// {@link ChatState.canvases} references. Repeating this command after the +// resource is gone is a no-op. +// +// Clients MUST NOT call this command unless the chat's agent advertises +// {@link AgentCapabilities.canvases}. +type DisposeCanvasParams 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"` +} + // Returns a list of session summaries. Used to populate session lists and sidebars. // // The session list is **not** part of the state tree because it can be arbitrarily diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go index 0c586075d..e6024e6c6 100644 --- a/clients/go/ahptypes/roundtrip_fixture_test.go +++ b/clients/go/ahptypes/roundtrip_fixture_test.go @@ -318,6 +318,14 @@ func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string { var v ResourceReadResult dec(&v) return enc(&v) + case "OpenCanvasParams": + var v OpenCanvasParams + dec(&v) + return enc(&v) + case "DisposeCanvasParams": + var v DisposeCanvasParams + dec(&v) + return enc(&v) default: t.Fatalf("%s: round-trip fixture: unknown wire type %q. Add a decode entry to decodeAndReencode.", name, typ) return "" diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 6f48cb83c..17e97dd01 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -786,6 +786,20 @@ type AgentInfo struct { // corresponding client commands MUST NOT be used. Sub-fields carry // per-capability options. type AgentCapabilities struct { + // Experimental Canvas API. + // + // The agent can materialize an exact chat's + // {@link ChatState.canvasRegistry | Canvas registry} through + // `chat/canvasRegistryRequested` and open a declared type with `openCanvas`. + // Live instances are published through the chat's canvas references and + // their `ahp-canvas:` state channels. + // + // Clients MUST treat absence as unsupported and SHOULD tolerate additive + // registry metadata while the Canvas channel remains experimental. This + // capability does not gate server-published Canvas references. + // + // Stability: 1.0 - Early development. + Canvases *CanvasesCapability `json:"canvases,omitempty"` // The agent can host more than one concurrent chat per session. When absent, // clients MUST NOT call `createChat` to open chats beyond the default one the // session starts with. An empty object `{}` advertises multi-chat without @@ -804,6 +818,13 @@ type AgentCapabilities struct { MultipleWorkingDirectories *MultipleWorkingDirectoriesCapability `json:"multipleWorkingDirectories,omitempty"` } +// Presence capability for the experimental Canvas registry and lifecycle +// commands. +// +// Stability: 1.0 - Early development. +type CanvasesCapability struct { +} + // Options for the {@link AgentCapabilities.multipleChats} capability. type MultipleChatsCapability struct { // The agent can fork a chat from a specific turn. When absent or `false`, @@ -1436,6 +1457,17 @@ type ChatState struct { // absent from {@link ChatSummary}; clients obtain it by subscribing to the // chat channel. BackgroundWork *[]BackgroundWork `json:"backgroundWork,omitempty"` + // Canvas types currently registered for this exact chat. + // + // Absence means the registry has not been materialized or is not currently + // available. Clients that need it MAY dispatch + // `chat/canvasRegistryRequested`; the host publishes the authoritative + // registry through `chat/canvasRegistryChanged`. An empty + // {@link CanvasRegistry.extensions} array is a loaded registry with no + // available canvas types. + // + // Stability: 1.0 - Early development. + CanvasRegistry *CanvasRegistry `json:"canvasRegistry,omitempty"` // Live canvases currently exposed by this chat. // // Entries intentionally contain only subscribable channel references. @@ -1480,6 +1512,37 @@ type ChatState struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// One extension or provider that declares canvas types for a chat. +type CanvasExtension struct { + // Stable opaque identifier. + Id string `json:"id"` + // Human-readable label, when available. + Label *string `json:"label,omitempty"` + // Canvas types declared by this extension/provider. `id` MUST be + // unique within this array. + Canvases []CanvasType `json:"canvases"` +} + +// One canvas type currently available to open. +type CanvasType struct { + // Provider-local canvas type identifier. + Id string `json:"id"` + // Human-readable canvas type label. + Label string `json:"label"` + // Description of what the canvas type does. + Description string `json:"description"` + // Whether opening this canvas requires an input flow. + RequiresInput bool `json:"requiresInput"` +} + +// Canvas types currently registered for one chat. +type CanvasRegistry struct { + // Extensions and providers that declare canvas types. `id` MUST be unique + // within this array. An empty array is an authoritative loaded registry with + // no types. + Extensions []CanvasExtension `json:"extensions"` +} + // Stable reference to a subscribable canvas channel. // // Chat state intentionally carries only this reference so the experimental @@ -1496,11 +1559,15 @@ type CanvasReference struct { // The client already knows the subscribed resource, so the state does not // redundantly carry its channel URI. type CanvasState struct { - // Stable caller-supplied instance identifier. + // Provider-facing instance identifier. + // + // AHP clients address this instance by the subscribed `ahp-canvas:` channel + // URI. They MUST NOT treat this provider identifier as a second resource + // identity. InstanceId string `json:"instanceId"` - // Owning extension/provider identifier. + // Owning declaration namespace. ExtensionId string `json:"extensionId"` - // Owning extension display name, when available. + // Owning namespace title, when available. ExtensionName *string `json:"extensionName,omitempty"` // Provider-local canvas type identifier. CanvasId string `json:"canvasId"` 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 dd291e656..972de8f38 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -1082,6 +1082,12 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatCanvasesChanged -> state.copy(canvases = action.value.canvases) + is StateActionChatCanvasRegistryRequested -> + state + + is StateActionChatCanvasRegistryChanged -> + state.copy(canvasRegistry = action.value.canvasRegistry) + is StateActionChatWorkingDirectorySet -> { val list = state.workingDirectories ?: emptyList() if (list.contains(action.value.directory)) { 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 23797a196..712a65b2d 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 @@ -60,6 +60,8 @@ value class ActionType(val rawValue: String) { val CHAT_MOVABLE_CHANGED: ActionType = ActionType("chat/movableChanged") val CHAT_CHANGESETS_CHANGED: ActionType = ActionType("chat/changesetsChanged") val CHAT_CANVASES_CHANGED: ActionType = ActionType("chat/canvasesChanged") + val CHAT_CANVAS_REGISTRY_REQUESTED: ActionType = ActionType("chat/canvasRegistryRequested") + val CHAT_CANVAS_REGISTRY_CHANGED: ActionType = ActionType("chat/canvasRegistryChanged") val CANVAS_STATE_CHANGED: ActionType = ActionType("canvas/stateChanged") val CHAT_WORKING_DIRECTORY_SET: ActionType = ActionType("chat/workingDirectorySet") val CHAT_WORKING_DIRECTORY_REMOVED: ActionType = ActionType("chat/workingDirectoryRemoved") @@ -793,6 +795,43 @@ data class ChatCanvasesChangedAction( val canvases: List? = null ) +/** + * Requests materialization of the chat's Canvas type registry. + * + * A host MUST reject the action when the chat's agent does not advertise + * {@link AgentCapabilities.canvases} or when the chat cannot materialize the + * registry. Materialization MUST NOT send a user message or invoke a model. + * + * The reducer is a no-op. The host remains authoritative and publishes the + * complete result through + * {@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}. + * Clients do not need to dispatch this action when + * {@link ChatState.canvasRegistry} is already present. + * + * Stability: 1.0 - Early development. + */ +@Serializable +data class ChatCanvasRegistryRequestedAction( + val type: ActionType +) + +/** + * The Canvas type registry for this chat changed. + * + * Replaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely. + * Set to `undefined` when the registry is no longer materialized or available. + * + * Stability: 1.0 - Early development. + */ +@Serializable +data class ChatCanvasRegistryChangedAction( + val type: ActionType, + /** + * Authoritative registry, or `undefined` to clear it. + */ + val canvasRegistry: CanvasRegistry? = null +) + @Serializable data class CanvasStateChangedAction( val type: ActionType, @@ -1770,6 +1809,8 @@ sealed interface StateAction @JvmInline value class StateActionChatMovableChanged(val value: ChatMovableChangedAction) : StateAction @JvmInline value class StateActionChatChangesetsChanged(val value: ChatChangesetsChangedAction) : StateAction @JvmInline value class StateActionChatCanvasesChanged(val value: ChatCanvasesChangedAction) : StateAction +@JvmInline value class StateActionChatCanvasRegistryRequested(val value: ChatCanvasRegistryRequestedAction) : StateAction +@JvmInline value class StateActionChatCanvasRegistryChanged(val value: ChatCanvasRegistryChangedAction) : StateAction @JvmInline value class StateActionCanvasStateChanged(val value: CanvasStateChangedAction) : StateAction @JvmInline value class StateActionSessionTitleChanged(val value: SessionTitleChangedAction) : StateAction @JvmInline value class StateActionChatUsage(val value: ChatUsageAction) : StateAction @@ -1893,6 +1934,8 @@ internal object StateActionSerializer : KSerializer { "chat/movableChanged" -> StateActionChatMovableChanged(input.json.decodeFromJsonElement(ChatMovableChangedAction.serializer(), element)) "chat/changesetsChanged" -> StateActionChatChangesetsChanged(input.json.decodeFromJsonElement(ChatChangesetsChangedAction.serializer(), element)) "chat/canvasesChanged" -> StateActionChatCanvasesChanged(input.json.decodeFromJsonElement(ChatCanvasesChangedAction.serializer(), element)) + "chat/canvasRegistryRequested" -> StateActionChatCanvasRegistryRequested(input.json.decodeFromJsonElement(ChatCanvasRegistryRequestedAction.serializer(), element)) + "chat/canvasRegistryChanged" -> StateActionChatCanvasRegistryChanged(input.json.decodeFromJsonElement(ChatCanvasRegistryChangedAction.serializer(), element)) "canvas/stateChanged" -> StateActionCanvasStateChanged(input.json.decodeFromJsonElement(CanvasStateChangedAction.serializer(), element)) "session/titleChanged" -> StateActionSessionTitleChanged(input.json.decodeFromJsonElement(SessionTitleChangedAction.serializer(), element)) "chat/usage" -> StateActionChatUsage(input.json.decodeFromJsonElement(ChatUsageAction.serializer(), element)) @@ -2009,6 +2052,8 @@ internal object StateActionSerializer : KSerializer { is StateActionChatMovableChanged -> output.json.encodeToJsonElement(ChatMovableChangedAction.serializer(), value.value) is StateActionChatChangesetsChanged -> output.json.encodeToJsonElement(ChatChangesetsChangedAction.serializer(), value.value) is StateActionChatCanvasesChanged -> output.json.encodeToJsonElement(ChatCanvasesChangedAction.serializer(), value.value) + is StateActionChatCanvasRegistryRequested -> output.json.encodeToJsonElement(ChatCanvasRegistryRequestedAction.serializer(), value.value) + is StateActionChatCanvasRegistryChanged -> output.json.encodeToJsonElement(ChatCanvasRegistryChangedAction.serializer(), value.value) is StateActionCanvasStateChanged -> output.json.encodeToJsonElement(CanvasStateChangedAction.serializer(), value.value) is StateActionSessionTitleChanged -> output.json.encodeToJsonElement(SessionTitleChangedAction.serializer(), value.value) is StateActionChatUsage -> output.json.encodeToJsonElement(ChatUsageAction.serializer(), value.value) 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 76e2ba4f2..d65579687 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 @@ -1024,6 +1024,46 @@ data class MoveChatResult( val session: String ) +@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, + /** + * Exact chat URI that owns the canvas instance. + */ + val chat: String, + /** + * Stable identifier of the extension that declares the canvas. + */ + val extensionId: String, + /** + * Provider-local canvas type identifier. + */ + val canvasId: String +) + +@Serializable +data class DisposeCanvasParams( + /** + * 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 ListSessionsParams( /** diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Messages.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Messages.generated.kt index 3209e3e15..8cf4f3b46 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Messages.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Messages.generated.kt @@ -140,6 +140,12 @@ object AhpCommands { fun disposeTerminal(id: Long, params: DisposeTerminalParams): JsonRpcRequest = JsonRpcRequest(id = id, method = "disposeTerminal", params = params) + fun openCanvas(id: Long, params: OpenCanvasParams): JsonRpcRequest = + JsonRpcRequest(id = id, method = "openCanvas", params = params) + + fun disposeCanvas(id: Long, params: DisposeCanvasParams): JsonRpcRequest = + JsonRpcRequest(id = id, method = "disposeCanvas", params = params) + fun resolveSessionConfig(id: Long, params: ResolveSessionConfigParams): JsonRpcRequest = JsonRpcRequest(id = id, method = "resolveSessionConfig", params = params) 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 6b86957b0..630de2367 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 @@ -1507,6 +1507,22 @@ data class AgentInfo( @Serializable data class AgentCapabilities( + /** + * Experimental Canvas API. + * + * The agent can materialize an exact chat's + * {@link ChatState.canvasRegistry | Canvas registry} through + * `chat/canvasRegistryRequested` and open a declared type with `openCanvas`. + * Live instances are published through the chat's canvas references and + * their `ahp-canvas:` state channels. + * + * Clients MUST treat absence as unsupported and SHOULD tolerate additive + * registry metadata while the Canvas channel remains experimental. This + * capability does not gate server-published Canvas references. + * + * Stability: 1.0 - Early development. + */ + val canvases: CanvasesCapability? = null, /** * The agent can host more than one concurrent chat per session. When absent, * clients MUST NOT call `createChat` to open chats beyond the default one the @@ -1529,6 +1545,15 @@ data class AgentCapabilities( val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null ) +/** + * Presence capability for the experimental Canvas registry and lifecycle + * commands. + * + * Stability: 1.0 - Early development. + */ +@Serializable +class CanvasesCapability + @Serializable data class MultipleChatsCapability( /** @@ -1858,6 +1883,19 @@ data class ChatState( * chat channel. */ val backgroundWork: List? = null, + /** + * Canvas types currently registered for this exact chat. + * + * Absence means the registry has not been materialized or is not currently + * available. Clients that need it MAY dispatch + * `chat/canvasRegistryRequested`; the host publishes the authoritative + * registry through `chat/canvasRegistryChanged`. An empty + * {@link CanvasRegistry.extensions} array is a loaded registry with no + * available canvas types. + * + * Stability: 1.0 - Early development. + */ + val canvasRegistry: CanvasRegistry? = null, /** * Live canvases currently exposed by this chat. * @@ -2241,6 +2279,53 @@ data class BackgroundSubagentWork( val chat: String ) +@Serializable +data class CanvasExtension( + /** + * Stable opaque identifier. + */ + val id: String, + /** + * Human-readable label, when available. + */ + val label: String? = null, + /** + * Canvas types declared by this extension/provider. `id` MUST be + * unique within this array. + */ + val canvases: List +) + +@Serializable +data class CanvasType( + /** + * Provider-local canvas type identifier. + */ + val id: String, + /** + * Human-readable canvas type label. + */ + val label: String, + /** + * Description of what the canvas type does. + */ + val description: String, + /** + * Whether opening this canvas requires an input flow. + */ + val requiresInput: Boolean +) + +@Serializable +data class CanvasRegistry( + /** + * Extensions and providers that declare canvas types. `id` MUST be unique + * within this array. An empty array is an authoritative loaded registry with + * no types. + */ + val extensions: List +) + @Serializable data class CanvasReference( /** @@ -2252,15 +2337,19 @@ data class CanvasReference( @Serializable data class CanvasState( /** - * Stable caller-supplied instance identifier. + * Provider-facing instance identifier. + * + * AHP clients address this instance by the subscribed `ahp-canvas:` channel + * URI. They MUST NOT treat this provider identifier as a second resource + * identity. */ val instanceId: String, /** - * Owning extension/provider identifier. + * Owning declaration namespace. */ val extensionId: String, /** - * Owning extension display name, when available. + * Owning namespace title, when available. */ val extensionName: String? = null, /** diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt index c24300907..8da55806c 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt @@ -47,6 +47,8 @@ import com.microsoft.agenthostprotocol.generated.TcpResetParams import com.microsoft.agenthostprotocol.generated.CreateTcpConnectionParams import com.microsoft.agenthostprotocol.generated.ResourceReadParams import com.microsoft.agenthostprotocol.generated.ResourceReadResult +import com.microsoft.agenthostprotocol.generated.OpenCanvasParams +import com.microsoft.agenthostprotocol.generated.DisposeCanvasParams import com.microsoft.agenthostprotocol.generated.JsonRpcErrorResponse import com.microsoft.agenthostprotocol.generated.JsonRpcNotification import com.microsoft.agenthostprotocol.generated.JsonRpcRequest @@ -281,6 +283,8 @@ class RoundTripCorpusTest { "CreateTcpConnectionParams" -> rt(CreateTcpConnectionParams.serializer()) "ResourceReadParams" -> rt(ResourceReadParams.serializer()) "ResourceReadResult" -> rt(ResourceReadResult.serializer()) + "OpenCanvasParams" -> rt(OpenCanvasParams.serializer()) + "DisposeCanvasParams" -> rt(DisposeCanvasParams.serializer()) else -> fail( "$file: unknown wire type \"$typeName\". " + "Add a decode entry to decodeAndReencode.", diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 591daf586..55b67d64f 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -15,8 +15,8 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, - BackgroundWork, CanvasReference, CanvasState, ChangesSummary, Changeset, ChangesetFile, - ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, + BackgroundWork, CanvasReference, CanvasRegistry, CanvasState, ChangesSummary, Changeset, + ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, Message, @@ -62,6 +62,8 @@ pub enum ActionType { ChatMovableChanged, ChatChangesetsChanged, ChatCanvasesChanged, + ChatCanvasRegistryRequested, + ChatCanvasRegistryChanged, CanvasStateChanged, ChatWorkingDirectorySet, ChatWorkingDirectoryRemoved, @@ -190,6 +192,12 @@ impl serde::Serialize for ActionType { Self::ChatMovableChanged => serializer.serialize_str("chat/movableChanged"), Self::ChatChangesetsChanged => serializer.serialize_str("chat/changesetsChanged"), Self::ChatCanvasesChanged => serializer.serialize_str("chat/canvasesChanged"), + Self::ChatCanvasRegistryRequested => { + serializer.serialize_str("chat/canvasRegistryRequested") + } + Self::ChatCanvasRegistryChanged => { + serializer.serialize_str("chat/canvasRegistryChanged") + } Self::CanvasStateChanged => serializer.serialize_str("canvas/stateChanged"), Self::ChatWorkingDirectorySet => serializer.serialize_str("chat/workingDirectorySet"), Self::ChatWorkingDirectoryRemoved => { @@ -364,6 +372,8 @@ impl<'de> serde::Deserialize<'de> for ActionType { "chat/movableChanged" => Self::ChatMovableChanged, "chat/changesetsChanged" => Self::ChatChangesetsChanged, "chat/canvasesChanged" => Self::ChatCanvasesChanged, + "chat/canvasRegistryRequested" => Self::ChatCanvasRegistryRequested, + "chat/canvasRegistryChanged" => Self::ChatCanvasRegistryChanged, "canvas/stateChanged" => Self::CanvasStateChanged, "chat/workingDirectorySet" => Self::ChatWorkingDirectorySet, "chat/workingDirectoryRemoved" => Self::ChatWorkingDirectoryRemoved, @@ -1163,6 +1173,37 @@ pub struct ChatCanvasesChangedAction { pub canvases: Option>, } +/// Requests materialization of the chat's Canvas type registry. +/// +/// A host MUST reject the action when the chat's agent does not advertise +/// {@link AgentCapabilities.canvases} or when the chat cannot materialize the +/// registry. Materialization MUST NOT send a user message or invoke a model. +/// +/// The reducer is a no-op. The host remains authoritative and publishes the +/// complete result through +/// {@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}. +/// Clients do not need to dispatch this action when +/// {@link ChatState.canvasRegistry} is already present. +/// +/// Stability: 1.0 - Early development. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatCanvasRegistryRequestedAction {} + +/// The Canvas type registry for this chat changed. +/// +/// Replaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely. +/// Set to `undefined` when the registry is no longer materialized or available. +/// +/// Stability: 1.0 - Early development. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ChatCanvasRegistryChangedAction { + /// Authoritative registry, or `undefined` to clear it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_registry: Option, +} + /// The presentation state for this canvas changed. /// /// Replaces the subscribed canvas channel state entirely. Full-replacement @@ -2506,6 +2547,10 @@ pub enum StateAction { ChatChangesetsChanged(ChatChangesetsChangedAction), #[serde(rename = "chat/canvasesChanged")] ChatCanvasesChanged(ChatCanvasesChangedAction), + #[serde(rename = "chat/canvasRegistryRequested")] + ChatCanvasRegistryRequested(ChatCanvasRegistryRequestedAction), + #[serde(rename = "chat/canvasRegistryChanged")] + ChatCanvasRegistryChanged(ChatCanvasRegistryChangedAction), #[serde(rename = "canvas/stateChanged")] CanvasStateChanged(CanvasStateChangedAction), #[serde(rename = "session/titleChanged")] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 5be59736f..89768249a 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -1055,6 +1055,54 @@ pub struct MoveChatResult { pub session: Uri, } +/// Opens one client-addressed canvas resource in a chat. +/// +/// The resulting live instance is published through the chat's +/// {@link ChatState.canvases} references and its `ahp-canvas:` state channel. +/// Repeating this command with the same `channel` is idempotent. Reusing it for +/// a different chat, extension, or canvas type MUST fail with `Conflict`. +/// Clients MUST observe +/// {@link ChatState.canvases} and the referenced `ahp-canvas:` channel for +/// authoritative membership, availability, and presentation state. +/// +/// Clients MUST NOT call this command unless the chat's agent advertises +/// {@link AgentCapabilities.canvases}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCanvasParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// Exact chat URI that owns the canvas instance. + pub chat: Uri, + /// Stable identifier of the extension that declares the canvas. + pub extension_id: String, + /// Provider-local canvas type identifier. + pub canvas_id: String, +} + +/// Closes and disposes one live canvas resource. +/// +/// The server removes the resource from the owning chat's +/// {@link ChatState.canvases} references. Repeating this command after the +/// resource is gone is a no-op. +/// +/// Clients MUST NOT call this command unless the chat's agent advertises +/// {@link AgentCapabilities.canvases}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisposeCanvasParams { + /// Channel URI this command targets. + pub channel: Uri, + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + /// Returns a list of session summaries. Used to populate session lists and sidebars. /// /// The session list is **not** part of the state tree because it can be arbitrarily diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index afa7eaefc..0637c764d 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1773,6 +1773,21 @@ pub struct AgentInfo { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AgentCapabilities { + /// Experimental Canvas API. + /// + /// The agent can materialize an exact chat's + /// {@link ChatState.canvasRegistry | Canvas registry} through + /// `chat/canvasRegistryRequested` and open a declared type with `openCanvas`. + /// Live instances are published through the chat's canvas references and + /// their `ahp-canvas:` state channels. + /// + /// Clients MUST treat absence as unsupported and SHOULD tolerate additive + /// registry metadata while the Canvas channel remains experimental. This + /// capability does not gate server-published Canvas references. + /// + /// Stability: 1.0 - Early development. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option, /// The agent can host more than one concurrent chat per session. When absent, /// clients MUST NOT call `createChat` to open chats beyond the default one the /// session starts with. An empty object `{}` advertises multi-chat without @@ -1793,6 +1808,14 @@ pub struct AgentCapabilities { pub multiple_working_directories: Option, } +/// Presence capability for the experimental Canvas registry and lifecycle +/// commands. +/// +/// Stability: 1.0 - Early development. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasesCapability {} + /// Options for the {@link AgentCapabilities.multipleChats} capability. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -2106,6 +2129,18 @@ pub struct ChatState { /// chat channel. #[serde(default, skip_serializing_if = "Option::is_none")] pub background_work: Option>, + /// Canvas types currently registered for this exact chat. + /// + /// Absence means the registry has not been materialized or is not currently + /// available. Clients that need it MAY dispatch + /// `chat/canvasRegistryRequested`; the host publishes the authoritative + /// registry through `chat/canvasRegistryChanged`. An empty + /// {@link CanvasRegistry.extensions} array is a loaded registry with no + /// available canvas types. + /// + /// Stability: 1.0 - Early development. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_registry: Option, /// Live canvases currently exposed by this chat. /// /// Entries intentionally contain only subscribable channel references. @@ -2158,6 +2193,44 @@ pub struct ChatState { pub meta: Option, } +/// One extension or provider that declares canvas types for a chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasExtension { + /// Stable opaque identifier. + pub id: String, + /// Human-readable label, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Canvas types declared by this extension/provider. `id` MUST be + /// unique within this array. + pub canvases: Vec, +} + +/// One canvas type currently available to open. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasType { + /// Provider-local canvas type identifier. + pub id: String, + /// Human-readable canvas type label. + pub label: String, + /// Description of what the canvas type does. + pub description: String, + /// Whether opening this canvas requires an input flow. + pub requires_input: bool, +} + +/// Canvas types currently registered for one chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasRegistry { + /// Extensions and providers that declare canvas types. `id` MUST be unique + /// within this array. An empty array is an authoritative loaded registry with + /// no types. + pub extensions: Vec, +} + /// Stable reference to a subscribable canvas channel. /// /// Chat state intentionally carries only this reference so the experimental @@ -2178,11 +2251,15 @@ pub struct CanvasReference { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CanvasState { - /// Stable caller-supplied instance identifier. + /// Provider-facing instance identifier. + /// + /// AHP clients address this instance by the subscribed `ahp-canvas:` channel + /// URI. They MUST NOT treat this provider identifier as a second resource + /// identity. pub instance_id: String, - /// Owning extension/provider identifier. + /// Owning declaration namespace. pub extension_id: String, - /// Owning extension display name, when available. + /// Owning namespace title, when available. #[serde(default, skip_serializing_if = "Option::is_none")] pub extension_name: Option, /// Provider-local canvas type identifier. diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index 5876331eb..a60e2b17a 100644 --- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -30,8 +30,8 @@ use ahp_types::{ actions::{ActionEnvelope, StateAction}, commands::{ ChangesetOperationTarget, ChatSource, CreateChatParams, CreateTcpConnectionParams, - Implementation, InitializeResult, ReconnectParams, ReconnectResult, ResourceReadParams, - ResourceReadResult, SubscribeParams, SubscribeResult, + DisposeCanvasParams, Implementation, InitializeResult, OpenCanvasParams, ReconnectParams, + ReconnectResult, ResourceReadParams, ResourceReadResult, SubscribeParams, SubscribeResult, }, common::StringOrMarkdown, messages::JsonRpcMessage, @@ -245,6 +245,8 @@ fn decode_and_reencode(file: &str, type_name: &str, input_json: &str) -> Result< "CreateTcpConnectionParams" => round_trip!(CreateTcpConnectionParams), "ResourceReadParams" => round_trip!(ResourceReadParams), "ResourceReadResult" => round_trip!(ResourceReadResult), + "OpenCanvasParams" => round_trip!(OpenCanvasParams), + "DisposeCanvasParams" => round_trip!(DisposeCanvasParams), other => Err(format!( "{}: unknown wire type {:?}. Add a decode entry to decode_and_reencode.", file, other diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 08ca1ebcf..a10ed9890 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -1221,6 +1221,11 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu state.canvases = a.canvases.clone(); ReduceOutcome::Applied } + StateAction::ChatCanvasRegistryRequested(_) => ReduceOutcome::NoOp, + StateAction::ChatCanvasRegistryChanged(a) => { + state.canvas_registry = a.canvas_registry.clone(); + ReduceOutcome::Applied + } StateAction::ChatWorkingDirectorySet(a) => { let list = state.working_directories.get_or_insert_with(Vec::new); if list.contains(&a.directory) { @@ -2378,6 +2383,7 @@ mod tests { provider: None, working_directories: None, changesets: None, + canvas_registry: None, canvases: None, config: None, turns: Vec::new(), diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 707d69c22..c8bb222d0 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -37,6 +37,8 @@ public enum ActionType: Codable, Sendable, Equatable { case chatMovableChanged case chatChangesetsChanged case chatCanvasesChanged + case chatCanvasRegistryRequested + case chatCanvasRegistryChanged case canvasStateChanged case chatWorkingDirectorySet case chatWorkingDirectoryRemoved @@ -152,6 +154,8 @@ public enum ActionType: Codable, Sendable, Equatable { case "chat/movableChanged": self = .chatMovableChanged case "chat/changesetsChanged": self = .chatChangesetsChanged case "chat/canvasesChanged": self = .chatCanvasesChanged + case "chat/canvasRegistryRequested": self = .chatCanvasRegistryRequested + case "chat/canvasRegistryChanged": self = .chatCanvasRegistryChanged case "canvas/stateChanged": self = .canvasStateChanged case "chat/workingDirectorySet": self = .chatWorkingDirectorySet case "chat/workingDirectoryRemoved": self = .chatWorkingDirectoryRemoved @@ -267,6 +271,8 @@ public enum ActionType: Codable, Sendable, Equatable { case .chatMovableChanged: try container.encode("chat/movableChanged") case .chatChangesetsChanged: try container.encode("chat/changesetsChanged") case .chatCanvasesChanged: try container.encode("chat/canvasesChanged") + case .chatCanvasRegistryRequested: try container.encode("chat/canvasRegistryRequested") + case .chatCanvasRegistryChanged: try container.encode("chat/canvasRegistryChanged") case .canvasStateChanged: try container.encode("canvas/stateChanged") case .chatWorkingDirectorySet: try container.encode("chat/workingDirectorySet") case .chatWorkingDirectoryRemoved: try container.encode("chat/workingDirectoryRemoved") @@ -1297,6 +1303,49 @@ public struct ChatCanvasesChangedAction: Codable, Sendable { } } +/// Requests materialization of the chat's Canvas type registry. +/// +/// A host MUST reject the action when the chat's agent does not advertise +/// {@link AgentCapabilities.canvases} or when the chat cannot materialize the +/// registry. Materialization MUST NOT send a user message or invoke a model. +/// +/// The reducer is a no-op. The host remains authoritative and publishes the +/// complete result through +/// {@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}. +/// Clients do not need to dispatch this action when +/// {@link ChatState.canvasRegistry} is already present. +/// +/// Stability: 1.0 - Early development. +public struct ChatCanvasRegistryRequestedAction: Codable, Sendable { + public var type: ActionType + + public init( + type: ActionType + ) { + self.type = type + } +} + +/// The Canvas type registry for this chat changed. +/// +/// Replaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely. +/// Set to `undefined` when the registry is no longer materialized or available. +/// +/// Stability: 1.0 - Early development. +public struct ChatCanvasRegistryChangedAction: Codable, Sendable { + public var type: ActionType + /// Authoritative registry, or `undefined` to clear it. + public var canvasRegistry: CanvasRegistry? + + public init( + type: ActionType, + canvasRegistry: CanvasRegistry? = nil + ) { + self.type = type + self.canvasRegistry = canvasRegistry + } +} + public struct CanvasStateChangedAction: Codable, Sendable { public var type: ActionType /// New authoritative canvas state. @@ -2671,6 +2720,8 @@ public enum StateAction: Codable, Sendable { case chatMovableChanged(ChatMovableChangedAction) case chatChangesetsChanged(ChatChangesetsChangedAction) case chatCanvasesChanged(ChatCanvasesChangedAction) + case chatCanvasRegistryRequested(ChatCanvasRegistryRequestedAction) + case chatCanvasRegistryChanged(ChatCanvasRegistryChangedAction) case canvasStateChanged(CanvasStateChangedAction) case sessionTitleChanged(SessionTitleChangedAction) case chatUsage(ChatUsageAction) @@ -2822,6 +2873,10 @@ public enum StateAction: Codable, Sendable { self = .chatChangesetsChanged(try ChatChangesetsChangedAction(from: decoder)) case "chat/canvasesChanged": self = .chatCanvasesChanged(try ChatCanvasesChangedAction(from: decoder)) + case "chat/canvasRegistryRequested": + self = .chatCanvasRegistryRequested(try ChatCanvasRegistryRequestedAction(from: decoder)) + case "chat/canvasRegistryChanged": + self = .chatCanvasRegistryChanged(try ChatCanvasRegistryChangedAction(from: decoder)) case "canvas/stateChanged": self = .canvasStateChanged(try CanvasStateChangedAction(from: decoder)) case "session/titleChanged": @@ -3014,6 +3069,8 @@ public enum StateAction: Codable, Sendable { case .chatMovableChanged(let v): try v.encode(to: encoder) case .chatChangesetsChanged(let v): try v.encode(to: encoder) case .chatCanvasesChanged(let v): try v.encode(to: encoder) + case .chatCanvasRegistryRequested(let v): try v.encode(to: encoder) + case .chatCanvasRegistryChanged(let v): try v.encode(to: encoder) case .canvasStateChanged(let v): try v.encode(to: encoder) case .sessionTitleChanged(let v): try v.encode(to: encoder) case .chatUsage(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 4d3853682..95125a7c3 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -1154,6 +1154,63 @@ public struct MoveChatResult: Codable, Sendable { } } +public struct OpenCanvasParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + /// Exact chat URI that owns the canvas instance. + public var chat: String + /// Stable identifier of the extension that declares the canvas. + public var extensionId: String + /// Provider-local canvas type identifier. + public var canvasId: String + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + case chat + case extensionId + case canvasId + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil, + chat: String, + extensionId: String, + canvasId: String + ) { + self.channel = channel + self.meta = meta + self.chat = chat + self.extensionId = extensionId + self.canvasId = canvasId + } +} + +public struct DisposeCanvasParams: Codable, Sendable { + /// Channel URI this command targets. + public var channel: String + /// Optional JSON-serializable metadata associated with this request. + /// Receivers MUST ignore keys they do not understand. + public var meta: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case channel + case meta = "_meta" + } + + public init( + channel: String, + meta: [String: AnyCodable]? = nil + ) { + self.channel = channel + self.meta = meta + } +} + public struct ListSessionsParams: Codable, Sendable { /// Channel URI this command targets. public var channel: String diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Messages.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Messages.generated.swift index 956693651..9f43ea302 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Messages.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Messages.generated.swift @@ -149,6 +149,14 @@ public enum AHPCommands { public static func disposeTerminal(id: Int, params: DisposeTerminalParams) -> JsonRpcRequest { JsonRpcRequest(id: id, method: "disposeTerminal", params: params) } + + public static func openCanvas(id: Int, params: OpenCanvasParams) -> JsonRpcRequest { + JsonRpcRequest(id: id, method: "openCanvas", params: params) + } + + public static func disposeCanvas(id: Int, params: DisposeCanvasParams) -> JsonRpcRequest { + JsonRpcRequest(id: id, method: "disposeCanvas", params: params) + } } /// Typed helper for constructing client → server notifications. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 0d6fcf81d..d469fc200 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1479,6 +1479,20 @@ public struct AgentInfo: Codable, Sendable { } public struct AgentCapabilities: Codable, Sendable { + /// Experimental Canvas API. + /// + /// The agent can materialize an exact chat's + /// {@link ChatState.canvasRegistry | Canvas registry} through + /// `chat/canvasRegistryRequested` and open a declared type with `openCanvas`. + /// Live instances are published through the chat's canvas references and + /// their `ahp-canvas:` state channels. + /// + /// Clients MUST treat absence as unsupported and SHOULD tolerate additive + /// registry metadata while the Canvas channel remains experimental. This + /// capability does not gate server-published Canvas references. + /// + /// Stability: 1.0 - Early development. + public var canvases: CanvasesCapability? /// The agent can host more than one concurrent chat per session. When absent, /// clients MUST NOT call `createChat` to open chats beyond the default one the /// session starts with. An empty object `{}` advertises multi-chat without @@ -1497,14 +1511,28 @@ public struct AgentCapabilities: Codable, Sendable { public var multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? public init( + canvases: CanvasesCapability? = nil, multipleChats: MultipleChatsCapability? = nil, multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil ) { + self.canvases = canvases self.multipleChats = multipleChats self.multipleWorkingDirectories = multipleWorkingDirectories } } +/// Presence capability for the experimental Canvas registry and lifecycle +/// commands. +/// +/// Stability: 1.0 - Early development. +public struct CanvasesCapability: Codable, Sendable { + + public init( + + ) { + } +} + public struct MultipleChatsCapability: Codable, Sendable { /// The agent can fork a chat from a specific turn. When absent or `false`, /// clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to @@ -1861,6 +1889,17 @@ public struct ChatState: Codable, Sendable { /// absent from {@link ChatSummary}; clients obtain it by subscribing to the /// chat channel. public var backgroundWork: [BackgroundWork]? + /// Canvas types currently registered for this exact chat. + /// + /// Absence means the registry has not been materialized or is not currently + /// available. Clients that need it MAY dispatch + /// `chat/canvasRegistryRequested`; the host publishes the authoritative + /// registry through `chat/canvasRegistryChanged`. An empty + /// {@link CanvasRegistry.extensions} array is a loaded registry with no + /// available canvas types. + /// + /// Stability: 1.0 - Early development. + public var canvasRegistry: CanvasRegistry? /// Live canvases currently exposed by this chat. /// /// Entries intentionally contain only subscribable channel references. @@ -1918,6 +1957,7 @@ public struct ChatState: Codable, Sendable { case workingDirectories case changesets case backgroundWork + case canvasRegistry case canvases case config case turns @@ -1943,6 +1983,7 @@ public struct ChatState: Codable, Sendable { workingDirectories: [String]? = nil, changesets: [Changeset]? = nil, backgroundWork: [BackgroundWork]? = nil, + canvasRegistry: CanvasRegistry? = nil, canvases: [CanvasReference]? = nil, config: ChatConfigState? = nil, turns: [Turn], @@ -1966,6 +2007,7 @@ public struct ChatState: Codable, Sendable { self.workingDirectories = workingDirectories self.changesets = changesets self.backgroundWork = backgroundWork + self.canvasRegistry = canvasRegistry self.canvases = canvases self.config = config self.turns = turns @@ -6197,6 +6239,62 @@ public struct EmptyState: Codable, Sendable { } } +public struct CanvasExtension: Codable, Sendable { + /// Stable opaque identifier. + public var id: String + /// Human-readable label, when available. + public var label: String? + /// Canvas types declared by this extension/provider. `id` MUST be + /// unique within this array. + public var canvases: [CanvasType] + + public init( + id: String, + label: String? = nil, + canvases: [CanvasType] + ) { + self.id = id + self.label = label + self.canvases = canvases + } +} + +public struct CanvasType: Codable, Sendable { + /// Provider-local canvas type identifier. + public var id: String + /// Human-readable canvas type label. + public var label: String + /// Description of what the canvas type does. + public var description: String + /// Whether opening this canvas requires an input flow. + public var requiresInput: Bool + + public init( + id: String, + label: String, + description: String, + requiresInput: Bool + ) { + self.id = id + self.label = label + self.description = description + self.requiresInput = requiresInput + } +} + +public struct CanvasRegistry: Codable, Sendable { + /// Extensions and providers that declare canvas types. `id` MUST be unique + /// within this array. An empty array is an authoritative loaded registry with + /// no types. + public var extensions: [CanvasExtension] + + public init( + extensions: [CanvasExtension] + ) { + self.extensions = extensions + } +} + public struct CanvasReference: Codable, Sendable { /// Canvas channel URI. Subscribe to this resource for the full state. public var resource: String @@ -6209,11 +6307,15 @@ public struct CanvasReference: Codable, Sendable { } public struct CanvasState: Codable, Sendable { - /// Stable caller-supplied instance identifier. + /// Provider-facing instance identifier. + /// + /// AHP clients address this instance by the subscribed `ahp-canvas:` channel + /// URI. They MUST NOT treat this provider identifier as a second resource + /// identity. public var instanceId: String - /// Owning extension/provider identifier. + /// Owning declaration namespace. public var extensionId: String - /// Owning extension display name, when available. + /// Owning namespace title, when available. public var extensionName: String? /// Provider-local canvas type identifier. public var canvasId: String diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 502d8fc95..2e2ae11bf 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -247,6 +247,14 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { next.canvases = a.canvases return next + case .chatCanvasRegistryRequested: + return state + + case .chatCanvasRegistryChanged(let a): + var next = state + next.canvasRegistry = a.canvasRegistry + return next + case .chatWorkingDirectorySet(let a): if (state.workingDirectories ?? []).contains(a.directory) { return state } var next = state @@ -1031,6 +1039,7 @@ public let clientDispatchableActions: Set = [ "chat/isReadChanged", "chat/isArchivedChanged", "chat/configChanged", + "chat/canvasRegistryRequested", "chat/inputAnswerChanged", "chat/inputCompleted", "session/customizationToggled", @@ -1053,6 +1062,7 @@ public func isClientDispatchable(_ action: StateAction) -> Bool { .chatPendingMessageSet, .chatPendingMessageRemoved, .chatQueuedMessagesReordered, .chatIsReadChanged, .chatIsArchivedChanged, .chatConfigChanged, + .chatCanvasRegistryRequested, .chatInputAnswerChanged, .chatInputCompleted, .sessionCustomizationToggled, .sessionMcpServerStartRequested, .sessionMcpServerStopRequested, diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift index 6b5fd8628..617ed5139 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift @@ -218,6 +218,10 @@ final class TypesRoundTripFixtureTests: XCTestCase { return try reencode(dec.decode(ResourceReadParams.self, from: inputData)) case "ResourceReadResult": return try reencode(dec.decode(ResourceReadResult.self, from: inputData)) + case "OpenCanvasParams": + return try reencode(dec.decode(OpenCanvasParams.self, from: inputData)) + case "DisposeCanvasParams": + return try reencode(dec.decode(DisposeCanvasParams.self, from: inputData)) default: throw FixtureError.message( "round-trip fixture: unknown wire type \"\(type)\". Add a decode entry to decodeAndReencode.") diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts index 39a23679e..7ad313f9d 100644 --- a/clients/typescript/test/types-round-trip.test.ts +++ b/clients/typescript/test/types-round-trip.test.ts @@ -67,6 +67,7 @@ import type { ChannelFrameParams, ChannelCreditParams } from '../src/types/commo import type { TcpDataParams, TcpEofParams, TcpResetParams } from '../src/types/channels-tcp/notifications.js'; import type { CreateTcpConnectionParams } from '../src/types/channels-tcp/commands.js'; import type { ChatSource, CreateChatParams } from '../src/types/channels-chat/commands.js'; +import type { DisposeCanvasParams, OpenCanvasParams } from '../src/types/channels-canvas/commands.js'; // ─── Fixture directory ─────────────────────────────────────────────────────── @@ -264,6 +265,8 @@ function bindToType(file: string, type: string, parsed: unknown): void { case 'CreateTcpConnectionParams': void (parsed as CreateTcpConnectionParams); break; case 'ResourceReadParams': void (parsed as ResourceReadParams); break; case 'ResourceReadResult': void (parsed as ResourceReadResult); break; + case 'OpenCanvasParams': void (parsed as OpenCanvasParams); break; + case 'DisposeCanvasParams': void (parsed as DisposeCanvasParams); break; default: throw new Error( `${file}: unknown wire type "${type}". Add a decode entry to bindToType.`, diff --git a/docs/.changes/20261012-canvas-discovery-commands.json b/docs/.changes/20261012-canvas-discovery-commands.json new file mode 100644 index 000000000..4eb9763a2 --- /dev/null +++ b/docs/.changes/20261012-canvas-discovery-commands.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Experimental exact-chat Canvas registry state, explicit cold-registry materialization, and URI-addressed `openCanvas` and `disposeCanvas` support." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 7bd2daa84..b46243539 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -77,6 +77,14 @@ When a client dispatches an action, the server applies it to the state and also | `chat/turnResume` | **Yes** | Resume the latest resumable errored turn without adding another message | | `chat/truncated` | **Yes** | Turn history truncated (with optional `turnId` cutoff) | +### Canvases (chat channel) + +| Type | Client-dispatchable? | When | +|---|---|---| +| `chat/canvasesChanged` | No | The chat's live Canvas channel references changed | +| `chat/canvasRegistryRequested` | **Yes** | A client asks the host to materialize the Canvas type registry | +| `chat/canvasRegistryChanged` | No | The host replaces or clears the authoritative Canvas type registry | + ### Tool Calls (chat channel) Tool calls follow a discriminated-union state machine — see [State Model — Tool Call Lifecycle](/guide/state-model#tool-call-lifecycle) for the full diagram. diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md index e84adfe25..39206862c 100644 --- a/docs/specification/chat-channel.md +++ b/docs/specification/chat-channel.md @@ -86,6 +86,47 @@ itself changes, the server dispatches [`chat/changesetsChanged`](/reference/chat#chatchangesetschangedaction), which fully replaces the catalogue or clears it when `changesets` is absent. +### Canvas type registry + +When the selected agent advertises +[`AgentCapabilities.canvases`](/reference/root#agentcapabilities), an exact chat +may expose a [`canvasRegistry`](/reference/chat#chatstate). This is the +authoritative set of Canvas **types** the chat can currently open. It is +separate from [`canvases`](/reference/chat#chatstate), which contains references +to already-open live Canvas **instances**. + +The registry normalizes extension/provider metadata: each +[`CanvasExtension`](/reference/canvas#canvasextension) appears once and carries +its own Canvas types. Clients key a type by its extension and Canvas type IDs. + +For a live chat, hosts keep this registry synchronized with provider +registration and dispatch +[`chat/canvasRegistryChanged`](/reference/chat#chatcanvasregistrychangedaction) +only when the authoritative set changes. Clients do not poll the list or use +`ChatState.canvases` changes as a registry-invalidation signal. + +Absence of `canvasRegistry` means it has not been materialized or is not +currently available. A client may dispatch +[`chat/canvasRegistryRequested`](/reference/chat#chatcanvasregistryrequestedaction) +to request materialization. This action is explicitly effectful: for a +persisted cold chat, the host may resume its backing runtime, discover +extensions, and execute extension startup code. The host MUST NOT create a +backing for a brand-new chat, send a user message, or invoke a model. The +action's reducer is a no-op; the host publishes the complete resulting registry +through `chat/canvasRegistryChanged`. + +To open a no-input declaration, the client sends +[`openCanvas`](/reference/canvas#opencanvas) with a client-chosen +`ahp-canvas:` URI in `channel`, the exact owning chat URI, and the declaration's +`extensionId` and `canvasId`. Reusing the same Canvas URI focuses or refreshes +that logical instance. Live membership and presentation then flow through +`ChatState.canvases` and the Canvas channel. + +To close a live instance, the client sends +[`disposeCanvas`](/reference/canvas#disposecanvas) with that instance's +`ahp-canvas:` URI. The host removes the chat membership and disposes the +provider-facing instance; disposing an already-removed resource is a no-op. + ### Per-chat configuration [`ChatState.config`](/reference/chat#chatstate) holds the chat's own values for diff --git a/docs/specification/subscriptions.md b/docs/specification/subscriptions.md index 644c14bde..3178df30d 100644 --- a/docs/specification/subscriptions.md +++ b/docs/specification/subscriptions.md @@ -25,7 +25,7 @@ All channels use the same `subscribe` and `unsubscribe` messages. | `ahp-automations://` | `AutomationState` | All automations visible to the client. This channel exists when `InitializeResult.automations` is present. | | `ahp-session:/` | `SessionState` | Session metadata and the session's list of chats. | | `ahp-chat:/` | `ChatState` | One chat: turns, streamed responses, tool calls, pending messages, input requests, and changesets. See [Chat Channel](/specification/chat-channel). | -| `ahp-canvas:/` | `CanvasState` | Experimental. One canvas. The URI is listed in `ChatState.canvases`. See [Canvas Channel](/reference/canvas). | +| `ahp-canvas:/` | `CanvasState` | Experimental. One canvas. The URI is listed in `ChatState.canvases` and is treated as an opaque resource identity. See [Canvas Channel](/reference/canvas). | | `ahp-terminal:/` | `TerminalState` | One terminal. See [Terminal Channel](/specification/terminal-channel). | | `ahp-tcp:/` | `EmptyState` | Experimental. One TCP connection that the host opened with `createTcpConnection`. Private to the client that created it. See [TCP Channel](/specification/tcp-channel). | | `ahp-changeset:/` | `ChangesetState` | One changeset. The client builds the URI from the `Changeset.uriTemplate` of a session or chat. | @@ -54,6 +54,7 @@ route any message using only its `method` and `params.channel`. | `fetchTurns`, `completions`, `disposeChat` | The chat URI | | `moveChat` | The URI of the chat to move | | `createTerminal`, `disposeTerminal` | The terminal URI. For `createTerminal`, this is the URI of the new terminal. | +| `openCanvas`, `disposeCanvas` | The canvas URI. For `openCanvas`, this is the URI of the canvas to open. | | `createTcpConnection` | The URI of the new TCP channel | | `invokeChangesetOperation` | The changeset URI | | `subscribe`, `unsubscribe` | The URI of the channel being subscribed to or released | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 0e915cce3..fa4b83800 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1460,6 +1460,34 @@ "type" ] }, + "ChatCanvasRegistryRequestedAction": { + "type": "object", + "description": "Requests materialization of the chat's Canvas type registry.\n\nA host MUST reject the action when the chat's agent does not advertise\n{@link AgentCapabilities.canvases} or when the chat cannot materialize the\nregistry. Materialization MUST NOT send a user message or invoke a model.\n\nThe reducer is a no-op. The host remains authoritative and publishes the\ncomplete result through\n{@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}.\nClients do not need to dispatch this action when\n{@link ChatState.canvasRegistry} is already present.\n\nStability: 1.0 - Early development.", + "properties": { + "type": { + "const": "chat/canvasRegistryRequested" + } + }, + "required": [ + "type" + ] + }, + "ChatCanvasRegistryChangedAction": { + "type": "object", + "description": "The Canvas type registry for this chat changed.\n\nReplaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely.\nSet to `undefined` when the registry is no longer materialized or available.\n\nStability: 1.0 - Early development.", + "properties": { + "type": { + "const": "chat/canvasRegistryChanged" + }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Authoritative registry, or `undefined` to clear it." + } + }, + "required": [ + "type" + ] + }, "ChatWorkingDirectorySetAction": { "type": "object", "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", @@ -3338,6 +3366,10 @@ "type": "object", "description": "Static capabilities an {@link AgentInfo} advertises. Modelled after MCP\ncapabilities: each field is opt-in and its presence (an empty object `{}`)\nsignals support, while absence means the feature is unsupported and the\ncorresponding client commands MUST NOT be used. Sub-fields carry\nper-capability options.", "properties": { + "canvases": { + "$ref": "#/$defs/CanvasesCapability", + "description": "Experimental Canvas API.\n\nThe agent can materialize an exact chat's\n{@link ChatState.canvasRegistry | Canvas registry} through\n`chat/canvasRegistryRequested` and open a declared type with `openCanvas`.\nLive instances are published through the chat's canvas references and\ntheir `ahp-canvas:` state channels.\n\nClients MUST treat absence as unsupported and SHOULD tolerate additive\nregistry metadata while the Canvas channel remains experimental. This\ncapability does not gate server-published Canvas references.\n\nStability: 1.0 - Early development." + }, "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding\ncreation mode." @@ -3348,6 +3380,11 @@ } } }, + "CanvasesCapability": { + "type": "object", + "description": "Presence capability for the experimental Canvas registry and lifecycle\ncommands.\n\nStability: 1.0 - Early development.", + "properties": {} + }, "MultipleChatsCapability": { "type": "object", "description": "Options for the {@link AgentCapabilities.multipleChats} capability.", @@ -5296,6 +5333,10 @@ }, "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Canvas types currently registered for this exact chat.\n\nAbsence means the registry has not been materialized or is not currently\navailable. Clients that need it MAY dispatch\n`chat/canvasRegistryRequested`; the host publishes the authoritative\nregistry through `chat/canvasRegistryChanged`. An empty\n{@link CanvasRegistry.extensions} array is a loaded registry with no\navailable canvas types.\n\nStability: 1.0 - Early development." + }, "canvases": { "type": "array", "items": { @@ -7477,6 +7518,75 @@ "title" ] }, + "CanvasExtension": { + "type": "object", + "description": "One extension or provider that declares canvas types for a chat.", + "properties": { + "id": { + "type": "string", + "description": "Stable opaque identifier." + }, + "label": { + "type": "string", + "description": "Human-readable label, when available." + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasType" + }, + "description": "Canvas types declared by this extension/provider. `id` MUST be\nunique within this array." + } + }, + "required": [ + "id", + "canvases" + ] + }, + "CanvasType": { + "type": "object", + "description": "One canvas type currently available to open.", + "properties": { + "id": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "label": { + "type": "string", + "description": "Human-readable canvas type label." + }, + "description": { + "type": "string", + "description": "Description of what the canvas type does." + }, + "requiresInput": { + "type": "boolean", + "description": "Whether opening this canvas requires an input flow." + } + }, + "required": [ + "id", + "label", + "description", + "requiresInput" + ] + }, + "CanvasRegistry": { + "type": "object", + "description": "Canvas types currently registered for one chat.", + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasExtension" + }, + "description": "Extensions and providers that declare canvas types. `id` MUST be unique\nwithin this array. An empty array is an authoritative loaded registry with\nno types." + } + }, + "required": [ + "extensions" + ] + }, "CanvasReference": { "type": "object", "description": "Stable reference to a subscribable canvas channel.\n\nChat state intentionally carries only this reference so the experimental\ncanvas presentation model can evolve without changing the stable chat\nchannel shape.", @@ -7496,15 +7606,15 @@ "properties": { "instanceId": { "type": "string", - "description": "Stable caller-supplied instance identifier." + "description": "Provider-facing instance identifier.\n\nAHP clients address this instance by the subscribed `ahp-canvas:` channel\nURI. They MUST NOT treat this provider identifier as a second resource\nidentity." }, "extensionId": { "type": "string", - "description": "Owning extension/provider identifier." + "description": "Owning declaration namespace." }, "extensionName": { "type": "string", - "description": "Owning extension display name, when available." + "description": "Owning namespace title, when available." }, "canvasId": { "type": "string", @@ -9512,6 +9622,12 @@ { "$ref": "#/$defs/ChatCanvasesChangedAction" }, + { + "$ref": "#/$defs/ChatCanvasRegistryRequestedAction" + }, + { + "$ref": "#/$defs/ChatCanvasRegistryChangedAction" + }, { "$ref": "#/$defs/CanvasStateChangedAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 46e0b5cd4..2b6e5634d 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -1576,6 +1576,57 @@ "channel" ] }, + "OpenCanvasParams": { + "type": "object", + "description": "Opens one client-addressed canvas resource in a chat.\n\nThe resulting live instance is published through the chat's\n{@link ChatState.canvases} references and its `ahp-canvas:` state channel.\nRepeating this command with the same `channel` is idempotent. Reusing it for\na different chat, extension, or canvas type MUST fail with `Conflict`.\nClients MUST observe\n{@link ChatState.canvases} and the referenced `ahp-canvas:` channel for\nauthoritative membership, availability, and presentation state.\n\nClients MUST NOT call this command unless the chat's agent advertises\n{@link AgentCapabilities.canvases}.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Client-chosen URI for the canvas resource, for example\n`ahp-canvas:/`." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "Exact chat URI that owns the canvas instance." + }, + "extensionId": { + "type": "string", + "description": "Stable identifier of the extension that declares the canvas." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas type identifier." + } + }, + "required": [ + "channel", + "chat", + "extensionId", + "canvasId" + ] + }, + "DisposeCanvasParams": { + "type": "object", + "description": "Closes and disposes one live canvas resource.\n\nThe server removes the resource from the owning chat's\n{@link ChatState.canvases} references. Repeating this command after the\nresource is gone is a no-op.\n\nClients MUST NOT call this command unless the chat's agent advertises\n{@link AgentCapabilities.canvases}.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "URI of the canvas resource to dispose." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, "CreateTerminalParams": { "type": "object", "description": "Creates a new terminal on the server.\n\nAfter creation, the client should subscribe to the terminal URI to receive\nstate updates. The server dispatches `root/terminalsChanged` to update the\nroot terminal list.", @@ -2638,6 +2689,10 @@ "type": "object", "description": "Static capabilities an {@link AgentInfo} advertises. Modelled after MCP\ncapabilities: each field is opt-in and its presence (an empty object `{}`)\nsignals support, while absence means the feature is unsupported and the\ncorresponding client commands MUST NOT be used. Sub-fields carry\nper-capability options.", "properties": { + "canvases": { + "$ref": "#/$defs/CanvasesCapability", + "description": "Experimental Canvas API.\n\nThe agent can materialize an exact chat's\n{@link ChatState.canvasRegistry | Canvas registry} through\n`chat/canvasRegistryRequested` and open a declared type with `openCanvas`.\nLive instances are published through the chat's canvas references and\ntheir `ahp-canvas:` state channels.\n\nClients MUST treat absence as unsupported and SHOULD tolerate additive\nregistry metadata while the Canvas channel remains experimental. This\ncapability does not gate server-published Canvas references.\n\nStability: 1.0 - Early development." + }, "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding\ncreation mode." @@ -2648,6 +2703,11 @@ } } }, + "CanvasesCapability": { + "type": "object", + "description": "Presence capability for the experimental Canvas registry and lifecycle\ncommands.\n\nStability: 1.0 - Early development.", + "properties": {} + }, "MultipleChatsCapability": { "type": "object", "description": "Options for the {@link AgentCapabilities.multipleChats} capability.", @@ -4596,6 +4656,10 @@ }, "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Canvas types currently registered for this exact chat.\n\nAbsence means the registry has not been materialized or is not currently\navailable. Clients that need it MAY dispatch\n`chat/canvasRegistryRequested`; the host publishes the authoritative\nregistry through `chat/canvasRegistryChanged`. An empty\n{@link CanvasRegistry.extensions} array is a loaded registry with no\navailable canvas types.\n\nStability: 1.0 - Early development." + }, "canvases": { "type": "array", "items": { @@ -6777,6 +6841,75 @@ "title" ] }, + "CanvasExtension": { + "type": "object", + "description": "One extension or provider that declares canvas types for a chat.", + "properties": { + "id": { + "type": "string", + "description": "Stable opaque identifier." + }, + "label": { + "type": "string", + "description": "Human-readable label, when available." + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasType" + }, + "description": "Canvas types declared by this extension/provider. `id` MUST be\nunique within this array." + } + }, + "required": [ + "id", + "canvases" + ] + }, + "CanvasType": { + "type": "object", + "description": "One canvas type currently available to open.", + "properties": { + "id": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "label": { + "type": "string", + "description": "Human-readable canvas type label." + }, + "description": { + "type": "string", + "description": "Description of what the canvas type does." + }, + "requiresInput": { + "type": "boolean", + "description": "Whether opening this canvas requires an input flow." + } + }, + "required": [ + "id", + "label", + "description", + "requiresInput" + ] + }, + "CanvasRegistry": { + "type": "object", + "description": "Canvas types currently registered for one chat.", + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasExtension" + }, + "description": "Extensions and providers that declare canvas types. `id` MUST be unique\nwithin this array. An empty array is an authoritative loaded registry with\nno types." + } + }, + "required": [ + "extensions" + ] + }, "CanvasReference": { "type": "object", "description": "Stable reference to a subscribable canvas channel.\n\nChat state intentionally carries only this reference so the experimental\ncanvas presentation model can evolve without changing the stable chat\nchannel shape.", @@ -6796,15 +6929,15 @@ "properties": { "instanceId": { "type": "string", - "description": "Stable caller-supplied instance identifier." + "description": "Provider-facing instance identifier.\n\nAHP clients address this instance by the subscribed `ahp-canvas:` channel\nURI. They MUST NOT treat this provider identifier as a second resource\nidentity." }, "extensionId": { "type": "string", - "description": "Owning extension/provider identifier." + "description": "Owning declaration namespace." }, "extensionName": { "type": "string", - "description": "Owning extension display name, when available." + "description": "Owning namespace title, when available." }, "canvasId": { "type": "string", @@ -9526,6 +9659,34 @@ "type" ] }, + "ChatCanvasRegistryRequestedAction": { + "type": "object", + "description": "Requests materialization of the chat's Canvas type registry.\n\nA host MUST reject the action when the chat's agent does not advertise\n{@link AgentCapabilities.canvases} or when the chat cannot materialize the\nregistry. Materialization MUST NOT send a user message or invoke a model.\n\nThe reducer is a no-op. The host remains authoritative and publishes the\ncomplete result through\n{@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}.\nClients do not need to dispatch this action when\n{@link ChatState.canvasRegistry} is already present.\n\nStability: 1.0 - Early development.", + "properties": { + "type": { + "const": "chat/canvasRegistryRequested" + } + }, + "required": [ + "type" + ] + }, + "ChatCanvasRegistryChangedAction": { + "type": "object", + "description": "The Canvas type registry for this chat changed.\n\nReplaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely.\nSet to `undefined` when the registry is no longer materialized or available.\n\nStability: 1.0 - Early development.", + "properties": { + "type": { + "const": "chat/canvasRegistryChanged" + }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Authoritative registry, or `undefined` to clear it." + } + }, + "required": [ + "type" + ] + }, "ChatWorkingDirectorySetAction": { "type": "object", "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", @@ -10828,6 +10989,12 @@ { "$ref": "#/$defs/ChatCanvasesChangedAction" }, + { + "$ref": "#/$defs/ChatCanvasRegistryRequestedAction" + }, + { + "$ref": "#/$defs/ChatCanvasRegistryChangedAction" + }, { "$ref": "#/$defs/CanvasStateChangedAction" }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index d42af0ade..ebe8fed9e 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -712,6 +712,10 @@ "type": "object", "description": "Static capabilities an {@link AgentInfo} advertises. Modelled after MCP\ncapabilities: each field is opt-in and its presence (an empty object `{}`)\nsignals support, while absence means the feature is unsupported and the\ncorresponding client commands MUST NOT be used. Sub-fields carry\nper-capability options.", "properties": { + "canvases": { + "$ref": "#/$defs/CanvasesCapability", + "description": "Experimental Canvas API.\n\nThe agent can materialize an exact chat's\n{@link ChatState.canvasRegistry | Canvas registry} through\n`chat/canvasRegistryRequested` and open a declared type with `openCanvas`.\nLive instances are published through the chat's canvas references and\ntheir `ahp-canvas:` state channels.\n\nClients MUST treat absence as unsupported and SHOULD tolerate additive\nregistry metadata while the Canvas channel remains experimental. This\ncapability does not gate server-published Canvas references.\n\nStability: 1.0 - Early development." + }, "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding\ncreation mode." @@ -722,6 +726,11 @@ } } }, + "CanvasesCapability": { + "type": "object", + "description": "Presence capability for the experimental Canvas registry and lifecycle\ncommands.\n\nStability: 1.0 - Early development.", + "properties": {} + }, "MultipleChatsCapability": { "type": "object", "description": "Options for the {@link AgentCapabilities.multipleChats} capability.", @@ -2670,6 +2679,10 @@ }, "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Canvas types currently registered for this exact chat.\n\nAbsence means the registry has not been materialized or is not currently\navailable. Clients that need it MAY dispatch\n`chat/canvasRegistryRequested`; the host publishes the authoritative\nregistry through `chat/canvasRegistryChanged`. An empty\n{@link CanvasRegistry.extensions} array is a loaded registry with no\navailable canvas types.\n\nStability: 1.0 - Early development." + }, "canvases": { "type": "array", "items": { @@ -4851,6 +4864,75 @@ "title" ] }, + "CanvasExtension": { + "type": "object", + "description": "One extension or provider that declares canvas types for a chat.", + "properties": { + "id": { + "type": "string", + "description": "Stable opaque identifier." + }, + "label": { + "type": "string", + "description": "Human-readable label, when available." + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasType" + }, + "description": "Canvas types declared by this extension/provider. `id` MUST be\nunique within this array." + } + }, + "required": [ + "id", + "canvases" + ] + }, + "CanvasType": { + "type": "object", + "description": "One canvas type currently available to open.", + "properties": { + "id": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "label": { + "type": "string", + "description": "Human-readable canvas type label." + }, + "description": { + "type": "string", + "description": "Description of what the canvas type does." + }, + "requiresInput": { + "type": "boolean", + "description": "Whether opening this canvas requires an input flow." + } + }, + "required": [ + "id", + "label", + "description", + "requiresInput" + ] + }, + "CanvasRegistry": { + "type": "object", + "description": "Canvas types currently registered for one chat.", + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasExtension" + }, + "description": "Extensions and providers that declare canvas types. `id` MUST be unique\nwithin this array. An empty array is an authoritative loaded registry with\nno types." + } + }, + "required": [ + "extensions" + ] + }, "CanvasReference": { "type": "object", "description": "Stable reference to a subscribable canvas channel.\n\nChat state intentionally carries only this reference so the experimental\ncanvas presentation model can evolve without changing the stable chat\nchannel shape.", @@ -4870,15 +4952,15 @@ "properties": { "instanceId": { "type": "string", - "description": "Stable caller-supplied instance identifier." + "description": "Provider-facing instance identifier.\n\nAHP clients address this instance by the subscribed `ahp-canvas:` channel\nURI. They MUST NOT treat this provider identifier as a second resource\nidentity." }, "extensionId": { "type": "string", - "description": "Owning extension/provider identifier." + "description": "Owning declaration namespace." }, "extensionName": { "type": "string", - "description": "Owning extension display name, when available." + "description": "Owning namespace title, when available." }, "canvasId": { "type": "string", @@ -7716,6 +7798,57 @@ "channel" ] }, + "OpenCanvasParams": { + "type": "object", + "description": "Opens one client-addressed canvas resource in a chat.\n\nThe resulting live instance is published through the chat's\n{@link ChatState.canvases} references and its `ahp-canvas:` state channel.\nRepeating this command with the same `channel` is idempotent. Reusing it for\na different chat, extension, or canvas type MUST fail with `Conflict`.\nClients MUST observe\n{@link ChatState.canvases} and the referenced `ahp-canvas:` channel for\nauthoritative membership, availability, and presentation state.\n\nClients MUST NOT call this command unless the chat's agent advertises\n{@link AgentCapabilities.canvases}.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "Client-chosen URI for the canvas resource, for example\n`ahp-canvas:/`." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + }, + "chat": { + "$ref": "#/$defs/URI", + "description": "Exact chat URI that owns the canvas instance." + }, + "extensionId": { + "type": "string", + "description": "Stable identifier of the extension that declares the canvas." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas type identifier." + } + }, + "required": [ + "channel", + "chat", + "extensionId", + "canvasId" + ] + }, + "DisposeCanvasParams": { + "type": "object", + "description": "Closes and disposes one live canvas resource.\n\nThe server removes the resource from the owning chat's\n{@link ChatState.canvases} references. Repeating this command after the\nresource is gone is a no-op.\n\nClients MUST NOT call this command unless the chat's agent advertises\n{@link AgentCapabilities.canvases}.", + "properties": { + "channel": { + "$ref": "#/$defs/URI", + "description": "URI of the canvas resource to dispose." + }, + "_meta": { + "type": "object", + "additionalProperties": {}, + "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand." + } + }, + "required": [ + "channel" + ] + }, "CreateTerminalParams": { "type": "object", "description": "Creates a new terminal on the server.\n\nAfter creation, the client should subscribe to the terminal URI to receive\nstate updates. The server dispatches `root/terminalsChanged` to update the\nroot terminal list.", @@ -9014,6 +9147,12 @@ { "$ref": "#/$defs/ChatCanvasesChangedAction" }, + { + "$ref": "#/$defs/ChatCanvasRegistryRequestedAction" + }, + { + "$ref": "#/$defs/ChatCanvasRegistryChangedAction" + }, { "$ref": "#/$defs/CanvasStateChangedAction" }, @@ -10608,6 +10747,34 @@ "type" ] }, + "ChatCanvasRegistryRequestedAction": { + "type": "object", + "description": "Requests materialization of the chat's Canvas type registry.\n\nA host MUST reject the action when the chat's agent does not advertise\n{@link AgentCapabilities.canvases} or when the chat cannot materialize the\nregistry. Materialization MUST NOT send a user message or invoke a model.\n\nThe reducer is a no-op. The host remains authoritative and publishes the\ncomplete result through\n{@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}.\nClients do not need to dispatch this action when\n{@link ChatState.canvasRegistry} is already present.\n\nStability: 1.0 - Early development.", + "properties": { + "type": { + "const": "chat/canvasRegistryRequested" + } + }, + "required": [ + "type" + ] + }, + "ChatCanvasRegistryChangedAction": { + "type": "object", + "description": "The Canvas type registry for this chat changed.\n\nReplaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely.\nSet to `undefined` when the registry is no longer materialized or available.\n\nStability: 1.0 - Early development.", + "properties": { + "type": { + "const": "chat/canvasRegistryChanged" + }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Authoritative registry, or `undefined` to clear it." + } + }, + "required": [ + "type" + ] + }, "CanvasStateChangedAction": { "type": "object", "description": "The presentation state for this canvas changed.\n\nReplaces the subscribed canvas channel state entirely. Full-replacement\nsemantics intentionally keep this early-development channel free to evolve\nwithout expanding the stable chat action surface.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index ee4eb76af..72178a34f 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -1014,6 +1014,10 @@ "type": "object", "description": "Static capabilities an {@link AgentInfo} advertises. Modelled after MCP\ncapabilities: each field is opt-in and its presence (an empty object `{}`)\nsignals support, while absence means the feature is unsupported and the\ncorresponding client commands MUST NOT be used. Sub-fields carry\nper-capability options.", "properties": { + "canvases": { + "$ref": "#/$defs/CanvasesCapability", + "description": "Experimental Canvas API.\n\nThe agent can materialize an exact chat's\n{@link ChatState.canvasRegistry | Canvas registry} through\n`chat/canvasRegistryRequested` and open a declared type with `openCanvas`.\nLive instances are published through the chat's canvas references and\ntheir `ahp-canvas:` state channels.\n\nClients MUST treat absence as unsupported and SHOULD tolerate additive\nregistry metadata while the Canvas channel remains experimental. This\ncapability does not gate server-published Canvas references.\n\nStability: 1.0 - Early development." + }, "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding\ncreation mode." @@ -1024,6 +1028,11 @@ } } }, + "CanvasesCapability": { + "type": "object", + "description": "Presence capability for the experimental Canvas registry and lifecycle\ncommands.\n\nStability: 1.0 - Early development.", + "properties": {} + }, "MultipleChatsCapability": { "type": "object", "description": "Options for the {@link AgentCapabilities.multipleChats} capability.", @@ -2972,6 +2981,10 @@ }, "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Canvas types currently registered for this exact chat.\n\nAbsence means the registry has not been materialized or is not currently\navailable. Clients that need it MAY dispatch\n`chat/canvasRegistryRequested`; the host publishes the authoritative\nregistry through `chat/canvasRegistryChanged`. An empty\n{@link CanvasRegistry.extensions} array is a loaded registry with no\navailable canvas types.\n\nStability: 1.0 - Early development." + }, "canvases": { "type": "array", "items": { @@ -5153,6 +5166,75 @@ "title" ] }, + "CanvasExtension": { + "type": "object", + "description": "One extension or provider that declares canvas types for a chat.", + "properties": { + "id": { + "type": "string", + "description": "Stable opaque identifier." + }, + "label": { + "type": "string", + "description": "Human-readable label, when available." + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasType" + }, + "description": "Canvas types declared by this extension/provider. `id` MUST be\nunique within this array." + } + }, + "required": [ + "id", + "canvases" + ] + }, + "CanvasType": { + "type": "object", + "description": "One canvas type currently available to open.", + "properties": { + "id": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "label": { + "type": "string", + "description": "Human-readable canvas type label." + }, + "description": { + "type": "string", + "description": "Description of what the canvas type does." + }, + "requiresInput": { + "type": "boolean", + "description": "Whether opening this canvas requires an input flow." + } + }, + "required": [ + "id", + "label", + "description", + "requiresInput" + ] + }, + "CanvasRegistry": { + "type": "object", + "description": "Canvas types currently registered for one chat.", + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasExtension" + }, + "description": "Extensions and providers that declare canvas types. `id` MUST be unique\nwithin this array. An empty array is an authoritative loaded registry with\nno types." + } + }, + "required": [ + "extensions" + ] + }, "CanvasReference": { "type": "object", "description": "Stable reference to a subscribable canvas channel.\n\nChat state intentionally carries only this reference so the experimental\ncanvas presentation model can evolve without changing the stable chat\nchannel shape.", @@ -5172,15 +5254,15 @@ "properties": { "instanceId": { "type": "string", - "description": "Stable caller-supplied instance identifier." + "description": "Provider-facing instance identifier.\n\nAHP clients address this instance by the subscribed `ahp-canvas:` channel\nURI. They MUST NOT treat this provider identifier as a second resource\nidentity." }, "extensionId": { "type": "string", - "description": "Owning extension/provider identifier." + "description": "Owning declaration namespace." }, "extensionName": { "type": "string", - "description": "Owning extension display name, when available." + "description": "Owning namespace title, when available." }, "canvasId": { "type": "string", diff --git a/schema/state.schema.json b/schema/state.schema.json index ef58f86b0..86bc9bced 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -623,6 +623,10 @@ "type": "object", "description": "Static capabilities an {@link AgentInfo} advertises. Modelled after MCP\ncapabilities: each field is opt-in and its presence (an empty object `{}`)\nsignals support, while absence means the feature is unsupported and the\ncorresponding client commands MUST NOT be used. Sub-fields carry\nper-capability options.", "properties": { + "canvases": { + "$ref": "#/$defs/CanvasesCapability", + "description": "Experimental Canvas API.\n\nThe agent can materialize an exact chat's\n{@link ChatState.canvasRegistry | Canvas registry} through\n`chat/canvasRegistryRequested` and open a declared type with `openCanvas`.\nLive instances are published through the chat's canvas references and\ntheir `ahp-canvas:` state channels.\n\nClients MUST treat absence as unsupported and SHOULD tolerate additive\nregistry metadata while the Canvas channel remains experimental. This\ncapability does not gate server-published Canvas references.\n\nStability: 1.0 - Early development." + }, "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nsource-based creation; set {@link MultipleChatsCapability.fork} or\n{@link MultipleChatsCapability.sideChat} to allow the corresponding\ncreation mode." @@ -633,6 +637,11 @@ } } }, + "CanvasesCapability": { + "type": "object", + "description": "Presence capability for the experimental Canvas registry and lifecycle\ncommands.\n\nStability: 1.0 - Early development.", + "properties": {} + }, "MultipleChatsCapability": { "type": "object", "description": "Options for the {@link AgentCapabilities.multipleChats} capability.", @@ -2581,6 +2590,10 @@ }, "description": "Work running in the background for this chat, such as shells and\nsubagents. Only active work is listed: hosts remove an entry once the work\nends. An entry may have been started by an earlier turn rather than the\n{@link ChatState.activeTurn | activeTurn}.\n\nLike {@link ChatState.changesets | changesets}, this is intentionally\nabsent from {@link ChatSummary}; clients obtain it by subscribing to the\nchat channel." }, + "canvasRegistry": { + "$ref": "#/$defs/CanvasRegistry", + "description": "Canvas types currently registered for this exact chat.\n\nAbsence means the registry has not been materialized or is not currently\navailable. Clients that need it MAY dispatch\n`chat/canvasRegistryRequested`; the host publishes the authoritative\nregistry through `chat/canvasRegistryChanged`. An empty\n{@link CanvasRegistry.extensions} array is a loaded registry with no\navailable canvas types.\n\nStability: 1.0 - Early development." + }, "canvases": { "type": "array", "items": { @@ -4762,6 +4775,75 @@ "title" ] }, + "CanvasExtension": { + "type": "object", + "description": "One extension or provider that declares canvas types for a chat.", + "properties": { + "id": { + "type": "string", + "description": "Stable opaque identifier." + }, + "label": { + "type": "string", + "description": "Human-readable label, when available." + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasType" + }, + "description": "Canvas types declared by this extension/provider. `id` MUST be\nunique within this array." + } + }, + "required": [ + "id", + "canvases" + ] + }, + "CanvasType": { + "type": "object", + "description": "One canvas type currently available to open.", + "properties": { + "id": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "label": { + "type": "string", + "description": "Human-readable canvas type label." + }, + "description": { + "type": "string", + "description": "Description of what the canvas type does." + }, + "requiresInput": { + "type": "boolean", + "description": "Whether opening this canvas requires an input flow." + } + }, + "required": [ + "id", + "label", + "description", + "requiresInput" + ] + }, + "CanvasRegistry": { + "type": "object", + "description": "Canvas types currently registered for one chat.", + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasExtension" + }, + "description": "Extensions and providers that declare canvas types. `id` MUST be unique\nwithin this array. An empty array is an authoritative loaded registry with\nno types." + } + }, + "required": [ + "extensions" + ] + }, "CanvasReference": { "type": "object", "description": "Stable reference to a subscribable canvas channel.\n\nChat state intentionally carries only this reference so the experimental\ncanvas presentation model can evolve without changing the stable chat\nchannel shape.", @@ -4781,15 +4863,15 @@ "properties": { "instanceId": { "type": "string", - "description": "Stable caller-supplied instance identifier." + "description": "Provider-facing instance identifier.\n\nAHP clients address this instance by the subscribed `ahp-canvas:` channel\nURI. They MUST NOT treat this provider identifier as a second resource\nidentity." }, "extensionId": { "type": "string", - "description": "Owning extension/provider identifier." + "description": "Owning declaration namespace." }, "extensionName": { "type": "string", - "description": "Owning extension display name, when available." + "description": "Owning namespace title, when available." }, "canvasId": { "type": "string", diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 0f71d3ec0..e1cd826ca 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -751,7 +751,7 @@ function generateDiscriminatedUnion(project: Project, cfg: UnionConfig): string const STATE_ENUMS = [ 'ChannelRecoveryKind', 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', - 'SessionOriginKind', + 'SessionOriginKind', 'CanvasExtensionSource', 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', 'TurnState', 'MessageKind', 'MessageAttachmentKind', 'ResponsePartKind', 'ToolCallStatus', @@ -781,6 +781,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'RootConfigState', mutable: true }, { name: 'AgentInfo' }, { name: 'AgentCapabilities' }, + { name: 'CanvasesCapability' }, { name: 'MultipleChatsCapability' }, { name: 'MultipleWorkingDirectoriesCapability' }, { name: 'SessionModelInfo' }, @@ -793,6 +794,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'BackgroundShellWork' }, { name: 'BackgroundSubagentWork' }, { name: 'ChatState', mutable: true }, + { name: 'CanvasExtension' }, + { name: 'CanvasType' }, + { name: 'CanvasRegistry' }, { name: 'CanvasReference' }, { name: 'CanvasState', mutable: true }, { name: 'ChatInputOption' }, @@ -1700,6 +1704,8 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'chat/canvasesChanged', variantName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'chat/canvasRegistryRequested', variantName: 'ChatCanvasRegistryRequested', tsInterface: 'ChatCanvasRegistryRequestedAction' }, + { type: 'chat/canvasRegistryChanged', variantName: 'ChatCanvasRegistryChanged', tsInterface: 'ChatCanvasRegistryChangedAction' }, { type: 'canvas/stateChanged', variantName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'chat/workingDirectorySet', variantName: 'ChatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, { type: 'chat/workingDirectoryRemoved', variantName: 'ChatWorkingDirectoryRemoved', tsInterface: 'ChatWorkingDirectoryRemovedAction' }, @@ -2333,6 +2339,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ChatMoveToSessionDestination' }, { name: 'ChatMoveToNewSessionDestination' }, { name: 'MoveChatParams' }, { name: 'MoveChatResult' }, + { name: 'OpenCanvasParams' }, { name: 'DisposeCanvasParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, { name: 'ResourceWriteParams' }, { name: 'ResourceWriteResult' }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index e0dfc2e17..a53f60014 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -733,7 +733,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'CanvasExtensionSource', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -749,6 +749,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'RootConfigState' }, { name: 'AgentInfo' }, { name: 'AgentCapabilities' }, + { name: 'CanvasesCapability' }, { name: 'MultipleChatsCapability' }, { name: 'MultipleWorkingDirectoriesCapability' }, { name: 'SessionModelInfo' }, @@ -766,6 +767,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'SessionChatSummary' }, { name: 'ChangesSummary' }, { name: 'ChatState' }, + { name: 'CanvasExtension' }, + { name: 'CanvasType' }, + { name: 'CanvasRegistry' }, { name: 'CanvasReference' }, { name: 'CanvasState' }, { name: 'ChatSummary' }, @@ -1618,6 +1622,8 @@ const ACTION_VARIANTS: { { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'chat/canvasesChanged', variantName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'chat/canvasRegistryRequested', variantName: 'ChatCanvasRegistryRequested', tsInterface: 'ChatCanvasRegistryRequestedAction' }, + { type: 'chat/canvasRegistryChanged', variantName: 'ChatCanvasRegistryChanged', tsInterface: 'ChatCanvasRegistryChangedAction' }, { type: 'canvas/stateChanged', variantName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, @@ -1817,6 +1823,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'DisposeSessionParams' }, { name: 'ForkChatSource' }, { name: 'SideChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, { name: 'ChatMoveToSessionDestination' }, { name: 'ChatMoveToNewSessionDestination' }, { name: 'MoveChatParams' }, { name: 'MoveChatResult' }, + { name: 'OpenCanvasParams' }, { name: 'DisposeCanvasParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, { name: 'ResourceWriteParams' }, { name: 'ResourceWriteResult' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 8cbffe506..cc8663e5e 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -996,7 +996,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'CanvasExtensionSource', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -1008,12 +1008,13 @@ const STATE_STRUCTS = [ 'ChannelFlowControl', 'ResumedChannelSubscription', 'Icon', 'ProtectedResourceMetadata', 'RootState', 'RootConfigState', 'AgentInfo', 'AgentCapabilities', + 'CanvasesCapability', 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', 'BackgroundShellWork', 'BackgroundSubagentWork', - 'CanvasReference', 'CanvasState', + 'CanvasExtension', 'CanvasType', 'CanvasRegistry', 'CanvasReference', 'CanvasState', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'SessionChatSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'ChatConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -1546,6 +1547,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/movableChanged', caseName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', caseName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'chat/canvasesChanged', caseName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'chat/canvasRegistryRequested', caseName: 'ChatCanvasRegistryRequested', tsInterface: 'ChatCanvasRegistryRequestedAction' }, + { type: 'chat/canvasRegistryChanged', caseName: 'ChatCanvasRegistryChanged', tsInterface: 'ChatCanvasRegistryChangedAction' }, { type: 'canvas/stateChanged', caseName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', caseName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, @@ -1798,6 +1801,7 @@ const COMMAND_STRUCTS = [ 'CreateSessionParams', 'DisposeSessionParams', 'CreateChatParams', 'DisposeChatParams', 'ChatMoveToSessionDestination', 'ChatMoveToNewSessionDestination', 'MoveChatParams', 'MoveChatResult', + 'OpenCanvasParams', 'DisposeCanvasParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -2355,6 +2359,12 @@ object AhpCommands { fun disposeTerminal(id: Long, params: DisposeTerminalParams): JsonRpcRequest = JsonRpcRequest(id = id, method = "disposeTerminal", params = params) + fun openCanvas(id: Long, params: OpenCanvasParams): JsonRpcRequest = + JsonRpcRequest(id = id, method = "openCanvas", params = params) + + fun disposeCanvas(id: Long, params: DisposeCanvasParams): JsonRpcRequest = + JsonRpcRequest(id = id, method = "disposeCanvas", params = params) + fun resolveSessionConfig(id: Long, params: ResolveSessionConfigParams): JsonRpcRequest = JsonRpcRequest(id = id, method = "resolveSessionConfig", params = params) diff --git a/scripts/generate-markdown.ts b/scripts/generate-markdown.ts index 144d7775d..acf2b6e71 100644 --- a/scripts/generate-markdown.ts +++ b/scripts/generate-markdown.ts @@ -602,6 +602,10 @@ function renderActionInterfaceBlock(iface: InterfaceDeclaration): string { } const heading = `### \`${labelBase}${variantSuffix}\``; lines.push(heading + '\n'); + const stability = readStability(iface); + if (stability) { + lines.push(`\n`); + } const isClientDispatchable = hasJsDocTag(iface, 'clientDispatchable'); const desc = getJsDocDescription(iface); const prefix = isClientDispatchable ? '**Client-dispatchable.** ' : ''; @@ -1017,6 +1021,7 @@ function generateCanvasChannelPage(project: Project): string { currentPage = 'canvas'; const stateSf = findChannelSourceFile(project, 'channels-canvas', 'state.ts'); const actionsSf = findChannelSourceFile(project, 'channels-canvas', 'actions.ts'); + const commandsSf = findChannelSourceFile(project, 'channels-canvas', 'commands.ts'); const lines: string[] = [GENERATED_HEADER]; lines.push('# Canvas Channel\n'); @@ -1034,6 +1039,11 @@ function generateCanvasChannelPage(project: Project): string { lines.push(schemaLink('actions.schema.json')); lines.push(emitActionsSection([actionsSf])); } + if (commandsSf) { + lines.push('## Commands\n'); + lines.push(schemaLink('commands.schema.json')); + lines.push(emitCommandsSection(project, [commandsSf])); + } return lines.join('\n'); } diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index e5e6ec622..9f9422ddb 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -842,7 +842,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'CanvasExtensionSource', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -879,6 +879,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'RootConfigState' }, { name: 'AgentInfo' }, { name: 'AgentCapabilities' }, + { name: 'CanvasesCapability' }, { name: 'MultipleChatsCapability' }, { name: 'MultipleWorkingDirectoriesCapability' }, { name: 'SessionModelInfo' }, @@ -888,6 +889,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ConfigSchema' }, { name: 'PendingMessage' }, { name: 'ChatState' }, + { name: 'CanvasExtension' }, + { name: 'CanvasType' }, + { name: 'CanvasRegistry' }, { name: 'CanvasReference' }, { name: 'CanvasState' }, { name: 'ChatSummary' }, @@ -1584,6 +1588,8 @@ const ACTION_VARIANTS: { { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'chat/canvasesChanged', variantName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'chat/canvasRegistryRequested', variantName: 'ChatCanvasRegistryRequested', tsInterface: 'ChatCanvasRegistryRequestedAction' }, + { type: 'chat/canvasRegistryChanged', variantName: 'ChatCanvasRegistryChanged', tsInterface: 'ChatCanvasRegistryChangedAction' }, { type: 'canvas/stateChanged', variantName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, @@ -1737,7 +1743,7 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, BackgroundWork, CanvasReference, CanvasState, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionConfigSchema, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, BackgroundWork, CanvasReference, CanvasRegistry, CanvasState, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionConfigSchema, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum lines.push('// ─── ActionType ──────────────────────────────────────────────────────\n'); @@ -1864,6 +1870,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'DisposeChatParams' }, { name: 'ChatMoveToSessionDestination', omitDiscriminants: true }, { name: 'ChatMoveToNewSessionDestination', omitDiscriminants: true }, { name: 'MoveChatParams' }, { name: 'MoveChatResult' }, + { name: 'OpenCanvasParams' }, { name: 'DisposeCanvasParams' }, { name: 'ListSessionsParams' }, { name: 'ListSessionsResult' }, { name: 'ResourceReadParams' }, { name: 'ResourceReadResult' }, { name: 'ResourceWriteParams' }, { name: 'ResourceWriteResult' }, diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 69ed7ac63..d6c4fc0b2 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -689,7 +689,7 @@ const STATE_ENUMS = [ 'BackgroundWorkKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', - 'SessionOriginKind', + 'SessionOriginKind', 'CanvasExtensionSource', 'AutomationOperation', 'AutomationMisfirePolicy', 'AutomationTriggerKind', 'AutomationDisableConditionKind', 'AutomationRunStatus', 'AutomationRunOriginKind', @@ -701,6 +701,7 @@ const STATE_STRUCTS = [ 'ChannelFlowControl', 'ResumedChannelSubscription', 'Icon', 'ProtectedResourceMetadata', 'RootState', 'RootConfigState', 'AgentInfo', 'AgentCapabilities', + 'CanvasesCapability', 'MultipleChatsCapability', 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', @@ -751,7 +752,7 @@ const STATE_STRUCTS = [ 'TerminalRunningLifecycleState', 'TerminalExitedLifecycleState', 'TerminalState', 'TerminalUnclassifiedPart', 'TerminalCommandPart', 'UsageInfo', 'ErrorInfo', 'Snapshot', 'EmptyState', - 'CanvasReference', 'CanvasState', + 'CanvasExtension', 'CanvasType', 'CanvasRegistry', 'CanvasReference', 'CanvasState', 'Changeset', 'ChangesetCapabilities', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', 'AnnotationsSummary', 'AnnotationsState', 'AnnotationOrigin', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', @@ -1459,6 +1460,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/movableChanged', caseName: 'chatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', caseName: 'chatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, { type: 'chat/canvasesChanged', caseName: 'chatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'chat/canvasRegistryRequested', caseName: 'chatCanvasRegistryRequested', tsInterface: 'ChatCanvasRegistryRequestedAction' }, + { type: 'chat/canvasRegistryChanged', caseName: 'chatCanvasRegistryChanged', tsInterface: 'ChatCanvasRegistryChangedAction' }, { type: 'canvas/stateChanged', caseName: 'canvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', caseName: 'sessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'chatUsage', tsInterface: 'ChatUsageAction' }, @@ -1720,6 +1723,7 @@ const COMMAND_STRUCTS = [ 'CreateSessionParams', 'DisposeSessionParams', 'CreateChatParams', 'DisposeChatParams', 'ChatMoveToSessionDestination', 'ChatMoveToNewSessionDestination', 'MoveChatParams', 'MoveChatResult', + 'OpenCanvasParams', 'DisposeCanvasParams', 'ListSessionsParams', 'ListSessionsResult', 'ResourceReadParams', 'ResourceReadResult', 'ResourceWriteParams', 'ResourceWriteResult', @@ -2286,6 +2290,14 @@ public enum AHPCommands { public static func disposeTerminal(id: Int, params: DisposeTerminalParams) -> JsonRpcRequest { JsonRpcRequest(id: id, method: "disposeTerminal", params: params) } + + public static func openCanvas(id: Int, params: OpenCanvasParams) -> JsonRpcRequest { + JsonRpcRequest(id: id, method: "openCanvas", params: params) + } + + public static func disposeCanvas(id: Int, params: DisposeCanvasParams) -> JsonRpcRequest { + JsonRpcRequest(id: id, method: "disposeCanvas", params: params) + } } /// Typed helper for constructing client → server notifications. diff --git a/scripts/read-stability.test.ts b/scripts/read-stability.test.ts index 92985e568..1f09aefe4 100644 --- a/scripts/read-stability.test.ts +++ b/scripts/read-stability.test.ts @@ -70,7 +70,7 @@ test('invalid, missing, and repeated stability values fail explicitly', () => { assert.equal(getDocumentation(ordinary), ''); }); -test('API docs show stability on entrypoint fields and notification methods only', () => { +test('API docs show stability on entrypoint fields and experimental actions', () => { const out = mkdtempSync(join(tmpdir(), 'ahp-stability-')); try { const project = new Project({ tsConfigFilePath: resolve(root, 'types/tsconfig.json') }); @@ -99,6 +99,13 @@ test('API docs show stability on entrypoint fields and notification methods only assert.ok(!common.includes('### `reconnect`\n\n')); + const chat = readFileSync(join(out, 'chat.md'), 'utf8'); + for (const action of ['chat/canvasRegistryRequested', 'chat/canvasRegistryChanged']) { + assert.ok(chat.includes(`### \`${action}\`\n\n`)); + } + for (const action of ['chat/turnStarted', 'chat/delta']) { + assert.ok(!chat.includes(`### \`${action}\`\n\n`. + */ + channel: URI; + /** Exact chat URI that owns the canvas instance. */ + chat: URI; + /** Stable identifier of the extension that declares the canvas. */ + extensionId: string; + /** Provider-local canvas type identifier. */ + canvasId: string; +} + +// ─── disposeCanvas ─────────────────────────────────────────────────────────── + +/** + * Closes and disposes one live canvas resource. + * + * The server removes the resource from the owning chat's + * {@link ChatState.canvases} references. Repeating this command after the + * resource is gone is a no-op. + * + * Clients MUST NOT call this command unless the chat's agent advertises + * {@link AgentCapabilities.canvases}. + * + * @category Commands + * @method disposeCanvas + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface DisposeCanvasParams extends BaseParams { + /** URI of the canvas resource to dispose. */ + channel: URI; +} diff --git a/types/channels-canvas/state.ts b/types/channels-canvas/state.ts index 64f5396f2..d8eabc4fc 100644 --- a/types/channels-canvas/state.ts +++ b/types/channels-canvas/state.ts @@ -9,6 +9,53 @@ import type { URI } from '../common/state.js'; +/** + * One extension or provider that declares canvas types for a chat. + * + * @category Canvas State + */ +export interface CanvasExtension { + /** Stable opaque identifier. */ + id: string; + /** Human-readable label, when available. */ + label?: string; + /** + * Canvas types declared by this extension/provider. `id` MUST be + * unique within this array. + */ + canvases: CanvasType[]; +} + +/** + * One canvas type currently available to open. + * + * @category Canvas State + */ +export interface CanvasType { + /** Provider-local canvas type identifier. */ + id: string; + /** Human-readable canvas type label. */ + label: string; + /** Description of what the canvas type does. */ + description: string; + /** Whether opening this canvas requires an input flow. */ + requiresInput: boolean; +} + +/** + * Canvas types currently registered for one chat. + * + * @category Canvas State + */ +export interface CanvasRegistry { + /** + * Extensions and providers that declare canvas types. `id` MUST be unique + * within this array. An empty array is an authoritative loaded registry with + * no types. + */ + extensions: CanvasExtension[]; +} + /** * Stable reference to a subscribable canvas channel. * @@ -19,7 +66,9 @@ import type { URI } from '../common/state.js'; * @category Canvas State */ export interface CanvasReference { - /** Canvas channel URI. Subscribe to this resource for the full state. */ + /** + * Canvas channel URI. Subscribe to this resource for the full state. + */ resource: URI; } @@ -33,11 +82,17 @@ export interface CanvasReference { * @category Canvas State */ export interface CanvasState { - /** Stable caller-supplied instance identifier. */ + /** + * Provider-facing instance identifier. + * + * AHP clients address this instance by the subscribed `ahp-canvas:` channel + * URI. They MUST NOT treat this provider identifier as a second resource + * identity. + */ instanceId: string; - /** Owning extension/provider identifier. */ + /** Owning declaration namespace. */ extensionId: string; - /** Owning extension display name, when available. */ + /** Owning namespace title, when available. */ extensionName?: string; /** Provider-local canvas type identifier. */ canvasId: string; diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 1b7dd4583..121827962 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -6,7 +6,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, FileEditCollection, UsageInfo, URI } from '../common/state.js'; -import type { CanvasReference } from '../channels-canvas/state.js'; +import type { CanvasReference, CanvasRegistry } from '../channels-canvas/state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { McpAuthRequirement, SessionConfigSchema } from '../channels-session/state.js'; import type { @@ -627,6 +627,44 @@ export interface ChatCanvasesChangedAction { canvases: CanvasReference[] | undefined; } +/** + * Requests materialization of the chat's Canvas type registry. + * + * A host MUST reject the action when the chat's agent does not advertise + * {@link AgentCapabilities.canvases} or when the chat cannot materialize the + * registry. Materialization MUST NOT send a user message or invoke a model. + * + * The reducer is a no-op. The host remains authoritative and publishes the + * complete result through + * {@link ChatCanvasRegistryChangedAction | `chat/canvasRegistryChanged`}. + * Clients do not need to dispatch this action when + * {@link ChatState.canvasRegistry} is already present. + * + * @category Chat Actions + * @version 1 + * @stability 1.0 + * @clientDispatchable + */ +export interface ChatCanvasRegistryRequestedAction { + type: ActionType.ChatCanvasRegistryRequested; +} + +/** + * The Canvas type registry for this chat changed. + * + * Replaces {@link ChatState.canvasRegistry | `state.canvasRegistry`} entirely. + * Set to `undefined` when the registry is no longer materialized or available. + * + * @category Chat Actions + * @version 1 + * @stability 1.0 + */ +export interface ChatCanvasRegistryChangedAction { + type: ActionType.ChatCanvasRegistryChanged; + /** Authoritative registry, or `undefined` to clear it. */ + canvasRegistry: CanvasRegistry | undefined; +} + /** * A working directory was added to this chat's * {@link ChatState.workingDirectories} subset. diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 6da04f39e..3ba0fc371 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -489,6 +489,16 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st : stateWithoutCanvases; } + case ActionType.ChatCanvasRegistryRequested: + return state; + + case ActionType.ChatCanvasRegistryChanged: { + const { canvasRegistry: _omit, ...stateWithoutCanvasRegistry } = state; + return action.canvasRegistry + ? { ...stateWithoutCanvasRegistry, canvasRegistry: action.canvasRegistry } + : stateWithoutCanvasRegistry; + } + // ── Working Directories ─────────────────────────────────────────────── case ActionType.ChatWorkingDirectorySet: { diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index eb3cf0cbf..8f836a363 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -16,7 +16,7 @@ import type { SessionStatus, } from '../channels-session/state.js'; import type { Changeset } from '../channels-changeset/state.js'; -import type { CanvasReference } from '../channels-canvas/state.js'; +import type { CanvasReference, CanvasRegistry } from '../channels-canvas/state.js'; import type { ContentRef, ErrorInfo, @@ -129,6 +129,18 @@ export interface ChatState { * chat channel. */ backgroundWork?: BackgroundWork[]; + /** + * Canvas types currently registered for this exact chat. + * + * Absence means the registry has not been materialized or is not currently + * available. Clients that need it MAY dispatch + * `chat/canvasRegistryRequested`; the host publishes the authoritative + * registry through `chat/canvasRegistryChanged`. An empty + * {@link CanvasRegistry.extensions} array is a loaded registry with no + * available canvas types. + * @stability 1.0 + */ + canvasRegistry?: CanvasRegistry; /** * Live canvases currently exposed by this chat. * diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index c1447309d..e252aae2e 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -106,6 +106,21 @@ export interface AgentInfo { * @category Root State */ export interface AgentCapabilities { + /** + * Experimental Canvas API. + * + * The agent can materialize an exact chat's + * {@link ChatState.canvasRegistry | Canvas registry} through + * `chat/canvasRegistryRequested` and open a declared type with `openCanvas`. + * Live instances are published through the chat's canvas references and + * their `ahp-canvas:` state channels. + * + * Clients MUST treat absence as unsupported and SHOULD tolerate additive + * registry metadata while the Canvas channel remains experimental. This + * capability does not gate server-published Canvas references. + * @stability 1.0 + */ + canvases?: CanvasesCapability; /** * The agent can host more than one concurrent chat per session. When absent, * clients MUST NOT call `createChat` to open chats beyond the default one the @@ -128,6 +143,15 @@ export interface AgentCapabilities { multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; } +/** + * Presence capability for the experimental Canvas registry and lifecycle + * commands. + * + * @category Root State + * @stability 1.0 + */ +export interface CanvasesCapability {} + /** * Options for the {@link AgentCapabilities.multipleChats} capability. * diff --git a/types/commands.ts b/types/commands.ts index 3bfd44073..763ccbfdb 100644 --- a/types/commands.ts +++ b/types/commands.ts @@ -11,6 +11,7 @@ export * from './common/commands.js'; export * from './channels-root/commands.js'; export * from './channels-session/commands.js'; export * from './channels-chat/commands.js'; +export * from './channels-canvas/commands.js'; export * from './channels-terminal/commands.js'; export * from './channels-changeset/commands.js'; export * from './channels-resource-watch/commands.js'; diff --git a/types/common/actions.ts b/types/common/actions.ts index 55125bf9e..f2ca54b93 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -71,6 +71,8 @@ import type { ChatMovableChangedAction, ChatChangesetsChangedAction, ChatCanvasesChangedAction, + ChatCanvasRegistryRequestedAction, + ChatCanvasRegistryChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, @@ -184,6 +186,8 @@ export const enum ActionType { ChatMovableChanged = 'chat/movableChanged', ChatChangesetsChanged = 'chat/changesetsChanged', ChatCanvasesChanged = 'chat/canvasesChanged', + ChatCanvasRegistryRequested = 'chat/canvasRegistryRequested', + ChatCanvasRegistryChanged = 'chat/canvasRegistryChanged', CanvasStateChanged = 'canvas/stateChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', @@ -353,6 +357,8 @@ export type StateAction = | ChatMovableChangedAction | ChatChangesetsChangedAction | ChatCanvasesChangedAction + | ChatCanvasRegistryRequestedAction + | ChatCanvasRegistryChangedAction | CanvasStateChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/types/common/messages.ts b/types/common/messages.ts index 010a266bc..a6d033969 100644 --- a/types/common/messages.ts +++ b/types/common/messages.ts @@ -60,6 +60,7 @@ import type { MoveChatResult, DisposeChatParams, } from '../channels-chat/commands.js'; +import type { DisposeCanvasParams, OpenCanvasParams } from '../channels-canvas/commands.js'; import type { CreateTerminalParams, DisposeTerminalParams, @@ -174,6 +175,8 @@ export interface CommandMap { 'createTcpConnection': { params: CreateTcpConnectionParams; result: null }; 'moveChat': { params: MoveChatParams; result: MoveChatResult }; 'disposeChat': { params: DisposeChatParams; result: null }; + 'openCanvas': { params: OpenCanvasParams; result: null }; + 'disposeCanvas': { params: DisposeCanvasParams; result: null }; 'createTerminal': { params: CreateTerminalParams; result: null }; 'disposeTerminal': { params: DisposeTerminalParams; result: null }; 'createResourceWatch': { params: CreateResourceWatchParams; result: CreateResourceWatchResult }; diff --git a/types/test-cases/reducers/327-chat-canvasregistryrequested-noop.json b/types/test-cases/reducers/327-chat-canvasregistryrequested-noop.json new file mode 100644 index 000000000..47f55868d --- /dev/null +++ b/types/test-cases/reducers/327-chat-canvasregistryrequested-noop.json @@ -0,0 +1,23 @@ +{ + "description": "chat/canvasRegistryRequested leaves state unchanged while the host materializes the registry", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/canvasRegistryRequested" + } + ], + "expected": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/test-cases/reducers/328-chat-canvasregistrychanged-sets-registry.json b/types/test-cases/reducers/328-chat-canvasregistrychanged-sets-registry.json new file mode 100644 index 000000000..0c897a5c2 --- /dev/null +++ b/types/test-cases/reducers/328-chat-canvasregistrychanged-sets-registry.json @@ -0,0 +1,55 @@ +{ + "description": "chat/canvasRegistryChanged replaces state.canvasRegistry with the authoritative normalized registry", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/canvasRegistryChanged", + "canvasRegistry": { + "extensions": [ + { + "id": "opaque-provider-id", + "label": "Project Canvases", + "canvases": [ + { + "id": "diagram", + "label": "Diagram", + "description": "Visualize a diagram.", + "requiresInput": false + } + ] + } + ] + } + } + ], + "expected": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "canvasRegistry": { + "extensions": [ + { + "id": "opaque-provider-id", + "label": "Project Canvases", + "canvases": [ + { + "id": "diagram", + "label": "Diagram", + "description": "Visualize a diagram.", + "requiresInput": false + } + ] + } + ] + } + } +} diff --git a/types/test-cases/reducers/329-chat-canvasregistrychanged-clears-registry.json b/types/test-cases/reducers/329-chat-canvasregistrychanged-clears-registry.json new file mode 100644 index 000000000..3d6826b91 --- /dev/null +++ b/types/test-cases/reducers/329-chat-canvasregistrychanged-clears-registry.json @@ -0,0 +1,26 @@ +{ + "description": "chat/canvasRegistryChanged clears state.canvasRegistry when the registry is no longer materialized", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "canvasRegistry": { + "extensions": [] + } + }, + "actions": [ + { + "type": "chat/canvasRegistryChanged" + } + ], + "expected": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/test-cases/round-trips/086-chat-canvas-registry.json b/types/test-cases/round-trips/086-chat-canvas-registry.json new file mode 100644 index 000000000..d84e687d2 --- /dev/null +++ b/types/test-cases/round-trips/086-chat-canvas-registry.json @@ -0,0 +1,74 @@ +{ + "name": "chat-canvas-registry", + "group": "A", + "description": "A chat snapshot preserves its normalized Canvas type registry separately from live Canvas references.", + "type": "Snapshot", + "input": { + "resource": "ahp-chat:/peer", + "state": { + "resource": "ahp-chat:/peer", + "title": "Peer", + "status": 1, + "modifiedAt": "2026-10-14T10:00:00.000Z", + "turns": [], + "canvasRegistry": { + "extensions": [ + { + "id": "opaque-provider-id", + "label": "Diagram Tools", + "canvases": [ + { + "id": "diagram", + "label": "Diagram", + "description": "Visualize a diagram.", + "requiresInput": false + }, + { + "id": "form", + "label": "Form", + "description": "Collect structured input.", + "requiresInput": true + } + ] + } + ] + } + }, + "fromSeq": 3 + }, + "acceptableOutputs": [ + { + "resource": "ahp-chat:/peer", + "state": { + "resource": "ahp-chat:/peer", + "title": "Peer", + "status": 1, + "modifiedAt": "2026-10-14T10:00:00.000Z", + "turns": [], + "canvasRegistry": { + "extensions": [ + { + "id": "opaque-provider-id", + "label": "Diagram Tools", + "canvases": [ + { + "id": "diagram", + "label": "Diagram", + "description": "Visualize a diagram.", + "requiresInput": false + }, + { + "id": "form", + "label": "Form", + "description": "Collect structured input.", + "requiresInput": true + } + ] + } + ] + } + }, + "fromSeq": 3 + } + ] +} diff --git a/types/test-cases/round-trips/087-chat-canvas-registry-requested-action.json b/types/test-cases/round-trips/087-chat-canvas-registry-requested-action.json new file mode 100644 index 000000000..fdca17e9b --- /dev/null +++ b/types/test-cases/round-trips/087-chat-canvas-registry-requested-action.json @@ -0,0 +1,14 @@ +{ + "name": "chat-canvas-registry-requested-action", + "group": "A", + "description": "chat/canvasRegistryRequested explicitly requests potentially effectful cold-registry materialization.", + "type": "StateAction", + "input": { + "type": "chat/canvasRegistryRequested" + }, + "acceptableOutputs": [ + { + "type": "chat/canvasRegistryRequested" + } + ] +} diff --git a/types/test-cases/round-trips/088-chat-canvas-registry-changed-action.json b/types/test-cases/round-trips/088-chat-canvas-registry-changed-action.json new file mode 100644 index 000000000..2d0f184ad --- /dev/null +++ b/types/test-cases/round-trips/088-chat-canvas-registry-changed-action.json @@ -0,0 +1,20 @@ +{ + "name": "chat-canvas-registry-changed-action", + "group": "A", + "description": "chat/canvasRegistryChanged carries a complete normalized registry replacement.", + "type": "StateAction", + "input": { + "type": "chat/canvasRegistryChanged", + "canvasRegistry": { + "extensions": [] + } + }, + "acceptableOutputs": [ + { + "type": "chat/canvasRegistryChanged", + "canvasRegistry": { + "extensions": [] + } + } + ] +} diff --git a/types/test-cases/round-trips/089-open-canvas-client-addressed-resource.json b/types/test-cases/round-trips/089-open-canvas-client-addressed-resource.json new file mode 100644 index 000000000..efae7eca1 --- /dev/null +++ b/types/test-cases/round-trips/089-open-canvas-client-addressed-resource.json @@ -0,0 +1,20 @@ +{ + "name": "open-canvas-client-addressed-resource", + "group": "A", + "description": "openCanvas targets a client-chosen ahp-canvas resource and names its exact owning chat and declaration.", + "type": "OpenCanvasParams", + "input": { + "channel": "ahp-canvas:/diagram", + "chat": "ahp-chat:/peer", + "extensionId": "opaque-provider-id", + "canvasId": "diagram" + }, + "acceptableOutputs": [ + { + "channel": "ahp-canvas:/diagram", + "chat": "ahp-chat:/peer", + "extensionId": "opaque-provider-id", + "canvasId": "diagram" + } + ] +} diff --git a/types/test-cases/round-trips/090-dispose-canvas-client-addressed-resource.json b/types/test-cases/round-trips/090-dispose-canvas-client-addressed-resource.json new file mode 100644 index 000000000..a26fcd528 --- /dev/null +++ b/types/test-cases/round-trips/090-dispose-canvas-client-addressed-resource.json @@ -0,0 +1,14 @@ +{ + "name": "dispose-canvas-client-addressed-resource", + "group": "A", + "description": "disposeCanvas targets the client-chosen ahp-canvas resource identity.", + "type": "DisposeCanvasParams", + "input": { + "channel": "ahp-canvas:/diagram" + }, + "acceptableOutputs": [ + { + "channel": "ahp-canvas:/diagram" + } + ] +} diff --git a/types/test-cases/round-trips/091-agent-canvas-capability.json b/types/test-cases/round-trips/091-agent-canvas-capability.json new file mode 100644 index 000000000..bcd0c8d66 --- /dev/null +++ b/types/test-cases/round-trips/091-agent-canvas-capability.json @@ -0,0 +1,54 @@ +{ + "name": "agent-canvas-capability", + "group": "A", + "description": "AgentInfo preserves the presence of the empty canvases capability object.", + "type": "InitializeResult", + "input": { + "protocolVersion": "1.1.0", + "serverSeq": 3, + "snapshots": [ + { + "resource": "ahp-root://", + "state": { + "agents": [ + { + "provider": "example", + "displayName": "Example", + "description": "Example agent", + "models": [], + "capabilities": { + "canvases": {} + } + } + ] + }, + "fromSeq": 3 + } + ] + }, + "acceptableOutputs": [ + { + "protocolVersion": "1.1.0", + "serverSeq": 3, + "snapshots": [ + { + "resource": "ahp-root://", + "state": { + "agents": [ + { + "provider": "example", + "displayName": "Example", + "description": "Example agent", + "models": [], + "capabilities": { + "canvases": {} + } + } + ] + }, + "fromSeq": 3 + } + ] + } + ] +} diff --git a/types/version/message-checks.ts b/types/version/message-checks.ts index 5e72d540e..6a7d63404 100644 --- a/types/version/message-checks.ts +++ b/types/version/message-checks.ts @@ -60,6 +60,8 @@ type _ExpectedCommands = | 'createTcpConnection' | 'moveChat' | 'disposeChat' + | 'openCanvas' + | 'disposeCanvas' | 'createTerminal' | 'disposeTerminal' | 'createResourceWatch' diff --git a/types/version/registry.test.ts b/types/version/registry.test.ts index 55b8ddade..543e6c9a7 100644 --- a/types/version/registry.test.ts +++ b/types/version/registry.test.ts @@ -112,3 +112,20 @@ test('chat/configChanged is available starting in protocol 1.1.0', () => { assert.equal(isActionKnownToVersion(action, '1.0.0'), false); assert.equal(isActionKnownToVersion(action, '1.1.0'), true); }); + +test('chat Canvas registry actions are available starting in protocol 1.1.0', () => { + const requested = { + type: ActionType.ChatCanvasRegistryRequested, + } as const; + const changed = { + type: ActionType.ChatCanvasRegistryChanged, + canvasRegistry: { extensions: [] }, + } as const; + + assert.equal(ACTION_INTRODUCED_IN[ActionType.ChatCanvasRegistryRequested], '1.1.0'); + assert.equal(ACTION_INTRODUCED_IN[ActionType.ChatCanvasRegistryChanged], '1.1.0'); + assert.equal(isActionKnownToVersion(requested, '1.0.0'), false); + assert.equal(isActionKnownToVersion(changed, '1.0.0'), false); + assert.equal(isActionKnownToVersion(requested, '1.1.0'), true); + assert.equal(isActionKnownToVersion(changed, '1.1.0'), true); +}); diff --git a/types/version/registry.ts b/types/version/registry.ts index d25e68643..397744016 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -153,6 +153,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatMovableChanged]: '0.9.0', [ActionType.ChatChangesetsChanged]: '0.9.0', [ActionType.ChatCanvasesChanged]: '0.10.0', + [ActionType.ChatCanvasRegistryRequested]: '1.1.0', + [ActionType.ChatCanvasRegistryChanged]: '1.1.0', [ActionType.CanvasStateChanged]: '0.10.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0',