From 4750303996e9d3daf9cfcf1c401a386b72aca688 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 2 Oct 2026 18:55:19 +0200 Subject: [PATCH] protocol: feat: add experimental canvas channels Keep stable chat state limited to subscribable canvas references while carrying live presentation state and source URLs on an independently experimental canvas channel. Generate reducer and mirror support across all six clients. Canvas state remains server-authoritative, so the contract intentionally does not add a separate agent capability until clients can initiate canvas operations independently. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 ++ clients/dotnet/release-metadata.json | 1 + .../Generated/Actions.generated.cs | 32 +++++ .../JsonSerializerContext.generated.cs | 4 + .../Generated/State.generated.cs | 72 +++++++++++- .../Generated/Version.generated.cs | 3 +- .../Generated/ActionMetadata.generated.cs | 6 + .../Hosts/MultiHostStateMirror.cs | 16 +++ .../dotnet/src/AgentHostProtocol/Reducers.cs | 23 ++++ .../FixtureDrivenReducerTests.cs | 3 + .../MultiHostStateMirrorTests.cs | 37 ++++++ clients/go/ahp/multi_host_state_mirror.go | 23 ++++ .../go/ahp/multi_host_state_mirror_test.go | 23 ++++ clients/go/ahp/reducers.go | 17 +++ clients/go/ahp/reducers_fixture_test.go | 2 + clients/go/ahptypes/actions.generated.go | 37 ++++++ clients/go/ahptypes/state.generated.go | 57 ++++++++- clients/go/ahptypes/version.generated.go | 3 +- clients/go/release-metadata.json | 1 + clients/kotlin/release-metadata.json | 1 + .../microsoft/agenthostprotocol/Reducers.kt | 19 ++- .../generated/Actions.generated.kt | 26 +++++ .../generated/State.generated.kt | 58 +++++++++- .../generated/Version.generated.kt | 3 +- .../FixtureDrivenReducerTest.kt | 13 +++ clients/rust/crates/ahp-types/src/actions.rs | 53 +++++++-- clients/rust/crates/ahp-types/src/state.rs | 57 ++++++++- clients/rust/crates/ahp-types/src/version.rs | 7 +- .../crates/ahp/src/multi_host_state_mirror.rs | 24 +++- clients/rust/crates/ahp/src/reducers.rs | 26 ++++- .../ahp/tests/multi_host_state_mirror.rs | 63 +++++++++- clients/rust/release-metadata.json | 1 + .../Generated/Actions.generated.swift | 42 +++++++ .../Generated/State.generated.swift | 68 ++++++++++- .../Generated/Version.generated.swift | 3 +- .../Sources/AgentHostProtocol/Reducers.swift | 13 +++ .../AHPStateMirror.swift | 8 ++ .../MultiHostStateMirror.swift | 9 ++ .../AHPStateMirrorTests.swift | 32 +++++ .../MultiHostStateMirrorTests.swift | 31 +++++ .../FixtureDrivenReducerTests.swift | 4 + clients/swift/release-metadata.json | 1 + clients/typescript/release-metadata.json | 1 + clients/typescript/src/client/canvas-state.ts | 21 ++++ .../src/client/hosts/state-mirror.ts | 33 +++++- clients/typescript/src/client/state-mirror.ts | 30 ++++- clients/typescript/test/client.test.ts | 53 +++++++++ clients/typescript/test/hosts.test.ts | 38 ++++++ docs/.changes/20260924-chat-canvases.json | 4 + docs/.vitepress/config.mts | 1 + docs/specification/subscriptions.md | 1 + docs/specification/versioning.md | 1 + schema/actions.schema.json | 109 +++++++++++++++++- schema/commands.schema.json | 106 ++++++++++++++++- schema/errors.schema.json | 106 ++++++++++++++++- schema/notifications.schema.json | 64 +++++++++- schema/state.schema.json | 64 +++++++++- scripts/find-protocol-sources.ts | 1 + scripts/generate-action-origin.ts | 41 ++++++- scripts/generate-csharp.ts | 18 ++- scripts/generate-go.ts | 18 ++- scripts/generate-kotlin.ts | 8 ++ scripts/generate-markdown.ts | 26 +++++ scripts/generate-rust.ts | 12 +- scripts/generate-swift.ts | 11 +- types/action-origin.generated.ts | 23 +++- types/actions.ts | 1 + types/channels-canvas/actions.ts | 24 ++++ types/channels-canvas/reducer.ts | 25 ++++ types/channels-canvas/state.ts | 56 +++++++++ types/channels-chat/actions.ts | 17 +++ types/channels-chat/reducer.ts | 7 ++ types/channels-chat/state.ts | 9 ++ types/common/actions.ts | 9 ++ types/common/state.ts | 5 +- types/index.ts | 1 + types/messages.test.ts | 1 + types/reducers.test.ts | 10 +- types/reducers.ts | 1 + types/state.ts | 1 + ...74-chat-canvaseschanged-sets-canvases.json | 33 ++++++ ...-chat-canvaseschanged-clears-canvases.json | 29 +++++ ...77-canvas-statechanged-replaces-state.json | 32 +++++ ...278-canvas-statechanged-clears-source.json | 30 +++++ ...9-canvas-statechanged-replaces-source.json | 33 ++++++ .../051-canvas-ready-snapshot.json | 32 +++++ .../052-canvas-unavailable-snapshot.json | 26 +++++ .../053-canvas-statechanged-action.json | 24 ++++ .../054-chat-canvas-references.json | 30 +++++ types/version/registry.ts | 5 +- 90 files changed, 2099 insertions(+), 62 deletions(-) create mode 100644 clients/typescript/src/client/canvas-state.ts create mode 100644 docs/.changes/20260924-chat-canvases.json create mode 100644 types/channels-canvas/actions.ts create mode 100644 types/channels-canvas/reducer.ts create mode 100644 types/channels-canvas/state.ts create mode 100644 types/test-cases/reducers/274-chat-canvaseschanged-sets-canvases.json create mode 100644 types/test-cases/reducers/275-chat-canvaseschanged-clears-canvases.json create mode 100644 types/test-cases/reducers/277-canvas-statechanged-replaces-state.json create mode 100644 types/test-cases/reducers/278-canvas-statechanged-clears-source.json create mode 100644 types/test-cases/reducers/279-canvas-statechanged-replaces-source.json create mode 100644 types/test-cases/round-trips/051-canvas-ready-snapshot.json create mode 100644 types/test-cases/round-trips/052-canvas-unavailable-snapshot.json create mode 100644 types/test-cases/round-trips/053-canvas-statechanged-action.json create mode 100644 types/test-cases/round-trips/054-chat-canvas-references.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c0687a6a7..5e66229a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). +## [0.10.0] — Unreleased + +Spec version: `0.10.0` + +### Added + +- Chat-owned canvas references and experimental per-canvas channels for live presentation state and source URLs. + ## [0.9.0] — 2026-08-28 Spec version: `0.9.0` diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json index 128ac239f..c745db9d9 100644 --- a/clients/dotnet/release-metadata.json +++ b/clients/dotnet/release-metadata.json @@ -2,6 +2,7 @@ "client": "dotnet", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 751701393..cd6b4c0a2 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -85,6 +85,10 @@ public ActionType(string value) public static readonly ActionType ChatChangesetsChanged = new ActionType("chat/changesetsChanged"); + public static readonly ActionType ChatCanvasesChanged = new ActionType("chat/canvasesChanged"); + + public static readonly ActionType CanvasStateChanged = new ActionType("canvas/stateChanged"); + public static readonly ActionType ChatWorkingDirectorySet = new ActionType("chat/workingDirectorySet"); public static readonly ActionType ChatWorkingDirectoryRemoved = new ActionType("chat/workingDirectoryRemoved"); @@ -1821,6 +1825,32 @@ public sealed record ChatChangesetsChangedAction public List? Changesets { get; init; } } +/// The live canvas channels exposed by this chat changed. +/// +/// Replaces {@link ChatState.canvases | `state.canvases`} entirely. Set to +/// `undefined` to clear the collection. +public sealed record ChatCanvasesChangedAction +{ + public ActionType Type { get; init; } = ActionType.ChatCanvasesChanged; + + /// New canvas channel references, or `undefined` to clear the collection. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Canvases { get; init; } +} + +/// The presentation state for this canvas changed. +/// +/// Replaces the subscribed canvas channel state entirely. Full-replacement +/// semantics intentionally keep this early-development channel free to evolve +/// without expanding the stable chat action surface. +public sealed record CanvasStateChangedAction +{ + public ActionType Type { get; init; } = ActionType.CanvasStateChanged; + + /// New authoritative canvas state. + public required CanvasState Canvas { get; init; } +} + /// A working directory was added to this chat's /// {@link ChatState.workingDirectories} subset. /// @@ -2801,6 +2831,8 @@ public StateActionConverter() ["chat/backgroundWorkRemoved"] = typeof(ChatBackgroundWorkRemovedAction), ["chat/movableChanged"] = typeof(ChatMovableChangedAction), ["chat/changesetsChanged"] = typeof(ChatChangesetsChangedAction), + ["chat/canvasesChanged"] = typeof(ChatCanvasesChangedAction), + ["canvas/stateChanged"] = typeof(CanvasStateChangedAction), ["chat/workingDirectorySet"] = typeof(ChatWorkingDirectorySetAction), ["chat/workingDirectoryRemoved"] = typeof(ChatWorkingDirectoryRemovedAction), ["chat/usage"] = typeof(ChatUsageAction), diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 8e496cb27..ec92d0727 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -81,6 +81,9 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(BackgroundSubagentWork))] [JsonSerializable(typeof(BackgroundWork))] [JsonSerializable(typeof(BackgroundWorkKind))] +[JsonSerializable(typeof(CanvasReference))] +[JsonSerializable(typeof(CanvasState))] +[JsonSerializable(typeof(CanvasStateChangedAction))] [JsonSerializable(typeof(Changeset))] [JsonSerializable(typeof(ChangesetCapabilities))] [JsonSerializable(typeof(ChangesetClearedAction))] @@ -105,6 +108,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(ChatActivityChangedAction))] [JsonSerializable(typeof(ChatBackgroundWorkRemovedAction))] [JsonSerializable(typeof(ChatBackgroundWorkSetAction))] +[JsonSerializable(typeof(ChatCanvasesChangedAction))] [JsonSerializable(typeof(ChatChangesetsChangedAction))] [JsonSerializable(typeof(ChatDeltaAction))] [JsonSerializable(typeof(ChatDraftChangedAction))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index b05b6b10e..2607fb22d 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -2420,6 +2420,14 @@ public sealed class ChatState [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? BackgroundWork { get; set; } + /// Live canvases currently exposed by this chat. + /// + /// Entries intentionally contain only subscribable channel references. + /// Clients subscribe to each resource for the experimental presentation + /// state, including its current live source URL. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Canvases { get; set; } + /// Completed turns public required List Turns { get; set; } @@ -2464,6 +2472,54 @@ public sealed class ChatState public Dictionary? Meta { get; set; } } +/// Stable reference to a subscribable canvas channel. +/// +/// Chat state intentionally carries only this reference so the experimental +/// canvas presentation model can evolve without changing the stable chat +/// channel shape. +public sealed record CanvasReference +{ + /// Canvas channel URI. Subscribe to this resource for the full state. + public required string Resource { get; init; } +} + +/// Full state for one live canvas, returned when a client subscribes to its +/// `ahp-canvas:` URI. +/// +/// The client already knows the subscribed resource, so the state does not +/// redundantly carry its channel URI. +public sealed class CanvasState +{ + /// Stable caller-supplied instance identifier. + public required string InstanceId { get; set; } + + /// Owning extension/provider identifier. + public required string ExtensionId { get; set; } + + /// Owning extension display name, when available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ExtensionName { get; set; } + + /// Provider-local canvas type identifier. + public required string CanvasId { get; set; } + + /// Provider-supplied title, when available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + /// Provider-supplied status text, when available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Status { get; set; } + + /// Current absolute HTTP(S) source URL; absent when the live source is unavailable. + /// Hosts MUST clear this field when the provider becomes unavailable. + /// + /// Source URLs MUST be redacted from diagnostic logs and MUST NOT be reused + /// from persisted state after a provider or host restart. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Url { get; set; } +} + /// A choice in a select-style question. public sealed record ChatInputOption { @@ -5866,7 +5922,7 @@ public sealed record ErrorInfo /// `initialize`, `reconnect`, and `subscribe`. public sealed record Snapshot { - /// The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/<uuid>`, or `ahp-chat:/<uuid>`) + /// The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/<uuid>`, `ahp-chat:/<uuid>`, or `ahp-canvas:/<uuid>`) public required string Resource { get; init; } /// The current state of the resource @@ -7726,10 +7782,10 @@ public override void Write(Utf8JsonWriter writer, ToolInput value, JsonSerialize /// /// SnapshotState is the state payload of a snapshot — root, session, - /// chat, terminal, changeset, resource-watch, annotations, automation catalogue, + /// chat, canvas, terminal, changeset, resource-watch, annotations, automation catalogue, /// or automation-run state. Read /// probes for distinctive fields in an order where no probe shadows another -/// (chat → session → terminal → changeset → resource-watch → annotations → root). +/// (chat → session → canvas → terminal → changeset → resource-watch → annotations → root). /// [JsonConverter(typeof(SnapshotStateConverter))] public sealed class SnapshotState @@ -7743,6 +7799,9 @@ public sealed class SnapshotState /// Chat state variant, when populated. public ChatState? Chat { get; set; } + /// Canvas state variant, when populated. + public CanvasState? Canvas { get; set; } + /// Terminal state variant, when populated. public TerminalState? Terminal { get; set; } @@ -7791,6 +7850,12 @@ public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert // session state was flattened.) result.Session = root.Deserialize(AhpJsonTypeInfo.Get(options)); } + else if (root.TryGetProperty("instanceId", out _) && + root.TryGetProperty("extensionId", out _) && + root.TryGetProperty("canvasId", out _)) + { + result.Canvas = root.Deserialize(AhpJsonTypeInfo.Get(options)); + } else if (root.TryGetProperty("content", out _)) { result.Terminal = root.Deserialize(AhpJsonTypeInfo.Get(options)); @@ -7819,6 +7884,7 @@ public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSeria if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } + if (value.Canvas is not null) { JsonSerializer.Serialize(writer, value.Canvas, AhpJsonTypeInfo.Get(options)); return; } if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, AhpJsonTypeInfo.Get(options)); return; } if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, AhpJsonTypeInfo.Get(options)); return; } if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, AhpJsonTypeInfo.Get(options)); return; } diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 1f8c3437d..0418dbbb8 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,10 +14,11 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "0.9.0"; + public const string Current = "0.10.0"; private static readonly string[] s_supported = { + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs index e20e88d36..488a0ad39 100644 --- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -55,6 +55,9 @@ public static bool TryGetActionType(object action, out ActionType actionType) case AutomationUpdateRequestedAction value: actionType = value.Type; return true; + case CanvasStateChangedAction value: + actionType = value.Type; + return true; case ChangesetClearedAction value: actionType = value.Type; return true; @@ -88,6 +91,9 @@ public static bool TryGetActionType(object action, out ActionType actionType) case ChatBackgroundWorkSetAction value: actionType = value.Type; return true; + case ChatCanvasesChangedAction value: + actionType = value.Type; + return true; case ChatChangesetsChangedAction value: actionType = value.Type; return true; diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs index c65378fcb..df13be267 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs @@ -21,6 +21,7 @@ public sealed class MultiHostStateMirror private readonly ConcurrentDictionary _roots = new(); private readonly ConcurrentDictionary _sessions = new(); private readonly ConcurrentDictionary _chats = new(); + private readonly ConcurrentDictionary _canvases = new(); private readonly ConcurrentDictionary _terminals = new(); private readonly ConcurrentDictionary _changesets = new(); @@ -62,6 +63,19 @@ public void PutChat(HostId hostId, string uri, ChatState state) public (ChatState? Value, bool Found) Chat(HostId hostId, string uri) => _chats.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); + /// Stores live canvas state under (hostId, uri). + public void PutCanvas(HostId hostId, string uri, CanvasState state) + { + Guard.ThrowIfNull(hostId, nameof(hostId)); + Guard.ThrowIfNull(uri, nameof(uri)); + Guard.ThrowIfNull(state, nameof(state)); + _canvases[new HostedResourceKey(hostId, uri)] = state; + } + + /// Returns canvas state at (hostId, uri), or (default, false). + public (CanvasState? Value, bool Found) Canvas(HostId hostId, string uri) => + _canvases.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); + /// Stores a terminal snapshot under (hostId, uri). public void PutTerminal(HostId hostId, string uri, TerminalState state) { @@ -94,6 +108,7 @@ public void DropHost(HostId hostId) _roots.TryRemove(hostId, out _); foreach (var k in _sessions.Keys) if (k.HostId.Equals(hostId)) _sessions.TryRemove(k, out _); foreach (var k in _chats.Keys) if (k.HostId.Equals(hostId)) _chats.TryRemove(k, out _); + foreach (var k in _canvases.Keys) if (k.HostId.Equals(hostId)) _canvases.TryRemove(k, out _); foreach (var k in _terminals.Keys) if (k.HostId.Equals(hostId)) _terminals.TryRemove(k, out _); foreach (var k in _changesets.Keys) if (k.HostId.Equals(hostId)) _changesets.TryRemove(k, out _); } @@ -104,6 +119,7 @@ public void DropResource(HostId hostId, string uri) var key = new HostedResourceKey(hostId, uri); _sessions.TryRemove(key, out _); _chats.TryRemove(key, out _); + _canvases.TryRemove(key, out _); _terminals.TryRemove(key, out _); _changesets.TryRemove(key, out _); } diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index af977f375..fa2145805 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -1012,6 +1012,9 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action) case ChatChangesetsChangedAction a: state.Changesets = CopyList(a.Changesets); return ReduceOutcome.Applied; + case ChatCanvasesChangedAction a: + state.Canvases = CopyList(a.Canvases); + return ReduceOutcome.Applied; case ChatWorkingDirectorySetAction a: { // Membership keyed by the directory URI, over this chat's subset of @@ -2100,6 +2103,26 @@ private static ReduceOutcome UpdateMcpServerCustomization( return ReduceOutcome.NoOp; } + /// Replaces live canvas state or returns OutOfScope. + public static ReduceOutcome ApplyToCanvas(CanvasState state, StateAction action) + { + Guard.ThrowIfNull(state, nameof(state)); + Guard.ThrowIfNull(action, nameof(action)); + if (action.Value is not CanvasStateChangedAction a) + { + return ReduceOutcome.OutOfScope; + } + + state.InstanceId = a.Canvas.InstanceId; + state.ExtensionId = a.Canvas.ExtensionId; + state.ExtensionName = a.Canvas.ExtensionName; + state.CanvasId = a.Canvas.CanvasId; + state.Title = a.Canvas.Title; + state.Status = a.Canvas.Status; + state.Url = a.Canvas.Url; + return ReduceOutcome.Applied; + } + // ─── Terminal Reducer ────────────────────────────────────────────────── /// diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs index 4bf9390e4..1299f7af6 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs @@ -78,6 +78,9 @@ public void ReducerMatchesFixture(string name, string path) case "chat": RunFixture(initial, expected, actions, Reducers.ApplyToChat); break; + case "canvas": + RunFixture(initial, expected, actions, Reducers.ApplyToCanvas); + break; case "automation": RunFixture( initial, diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs index 7468528ed..260f223a9 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostStateMirrorTests.cs @@ -16,6 +16,43 @@ namespace Microsoft.AgentHostProtocol.Tests; public sealed class MultiHostStateMirrorTests { + [Fact] + public void CanvasState_IsIsolatedAndDroppedWithItsResourceAndHost() + { + var mirror = new MultiHostStateMirror(); + const string uri = "ahp-canvas:/preview"; + CanvasState State() => new() + { + InstanceId = "preview", + ExtensionId = "project:preview", + CanvasId = "preview", + Url = "https://example.test/original", + }; + mirror.PutCanvas("host-a", uri, State()); + mirror.PutCanvas("host-b", uri, State()); + var (target, _) = mirror.Canvas("host-a", uri); + Reducers.ApplyToCanvas(target!, new StateAction(new CanvasStateChangedAction + { + Type = ActionType.CanvasStateChanged, + Canvas = new CanvasState + { + InstanceId = "preview", + ExtensionId = "project:preview", + CanvasId = "preview", + }, + })); + var (retained, _) = mirror.Canvas("host-b", uri); + var targeted = new[] { target!.Url, retained!.Url }; + mirror.DropResource("host-a", uri); + var (_, resourceFound) = mirror.Canvas("host-a", uri); + mirror.PutCanvas("host-a", uri, State()); + mirror.DropHost("host-a"); + var (_, hostFound) = mirror.Canvas("host-a", uri); + var (_, otherFound) = mirror.Canvas("host-b", uri); + Assert.Equal(new string?[] { null, "https://example.test/original" }, targeted); + Assert.Equal(new[] { false, false, true }, new[] { resourceFound, hostFound, otherFound }); + } + // A minimal-but-valid RootState carrying a distinguishing active-session // count so two hosts' roots are observably different snapshots. private static RootState Root(long activeSessions) => new() diff --git a/clients/go/ahp/multi_host_state_mirror.go b/clients/go/ahp/multi_host_state_mirror.go index a47bf441b..97bc5e46f 100644 --- a/clients/go/ahp/multi_host_state_mirror.go +++ b/clients/go/ahp/multi_host_state_mirror.go @@ -29,6 +29,7 @@ type MultiHostStateMirror struct { roots map[string]ahptypes.RootState session map[HostedResourceKey]ahptypes.SessionState chat map[HostedResourceKey]ahptypes.ChatState + canvas map[HostedResourceKey]ahptypes.CanvasState term map[HostedResourceKey]ahptypes.TerminalState changes map[HostedResourceKey]ahptypes.ChangesetState automationCat map[string]ahptypes.AutomationState @@ -42,6 +43,7 @@ func NewMultiHostStateMirror() *MultiHostStateMirror { roots: make(map[string]ahptypes.RootState), session: make(map[HostedResourceKey]ahptypes.SessionState), chat: make(map[HostedResourceKey]ahptypes.ChatState), + canvas: make(map[HostedResourceKey]ahptypes.CanvasState), term: make(map[HostedResourceKey]ahptypes.TerminalState), changes: make(map[HostedResourceKey]ahptypes.ChangesetState), automationCat: make(map[string]ahptypes.AutomationState), @@ -98,6 +100,21 @@ func (m *MultiHostStateMirror) Chat(hostID string, uri ahptypes.URI) (ahptypes.C return v, ok } +// PutCanvas stores live canvas state under (hostID, uri). +func (m *MultiHostStateMirror) PutCanvas(hostID string, uri ahptypes.URI, state ahptypes.CanvasState) { + m.mu.Lock() + defer m.mu.Unlock() + m.canvas[HostedResourceKey{hostID, uri}] = state +} + +// Canvas returns the canvas state at (hostID, uri), or (zero, false). +func (m *MultiHostStateMirror) Canvas(hostID string, uri ahptypes.URI) (ahptypes.CanvasState, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + v, ok := m.canvas[HostedResourceKey{hostID, uri}] + return v, ok +} + // PutTerminal stores a terminal snapshot under (hostID, uri). func (m *MultiHostStateMirror) PutTerminal(hostID string, uri ahptypes.URI, t ahptypes.TerminalState) { m.mu.Lock() @@ -194,6 +211,11 @@ func (m *MultiHostStateMirror) DropHost(hostID string) { delete(m.chat, k) } } + for k := range m.canvas { + if k.HostID == hostID { + delete(m.canvas, k) + } + } for k := range m.term { if k.HostID == hostID { delete(m.term, k) @@ -232,6 +254,7 @@ func (m *MultiHostStateMirror) DropResource(hostID string, uri ahptypes.URI) { } delete(m.session, k) delete(m.chat, k) + delete(m.canvas, k) delete(m.term, k) delete(m.changes, k) delete(m.automation, k) diff --git a/clients/go/ahp/multi_host_state_mirror_test.go b/clients/go/ahp/multi_host_state_mirror_test.go index 160c8115a..4aa6e589c 100644 --- a/clients/go/ahp/multi_host_state_mirror_test.go +++ b/clients/go/ahp/multi_host_state_mirror_test.go @@ -1,11 +1,34 @@ package ahp import ( + "reflect" "testing" "github.com/microsoft/agent-host-protocol/clients/go/ahptypes" ) +func TestMultiHostStateMirrorCanvasLifecycle(t *testing.T) { + mirror := NewMultiHostStateMirror() + uri := ahptypes.URI("ahp-canvas:/preview") + state := ahptypes.CanvasState{ + InstanceId: "preview", + ExtensionId: "project:preview", + CanvasId: "preview", + } + mirror.PutCanvas("host-a", uri, state) + mirror.PutCanvas("host-b", uri, state) + mirror.DropResource("host-a", uri) + _, removed := mirror.Canvas("host-a", uri) + retained, found := mirror.Canvas("host-b", uri) + mirror.PutCanvas("host-a", uri, state) + mirror.DropHost("host-a") + _, dropped := mirror.Canvas("host-a", uri) + _, otherHost := mirror.Canvas("host-b", uri) + if removed || !found || !reflect.DeepEqual(retained, state) || dropped || !otherHost { + t.Fatal("canvas state was not isolated or removed with its resource and host") + } +} + func TestMultiHostStateMirrorDropHostWithoutChangesets(t *testing.T) { mirror := NewMultiHostStateMirror() automationURI := ahptypes.URI("ahp-automation:/nightly") diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index ebef72956..3a2c98721 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -618,6 +618,13 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R state.Changesets = append([]ahptypes.Changeset(nil), a.Changesets...) } return ReduceOutcomeApplied + case *ahptypes.ChatCanvasesChangedAction: + if a.Canvases == nil { + state.Canvases = nil + } else { + state.Canvases = append([]ahptypes.CanvasReference(nil), a.Canvases...) + } + return ReduceOutcomeApplied case *ahptypes.ChatWorkingDirectorySetAction: for _, d := range state.WorkingDirectories { if d == a.Directory { @@ -1685,6 +1692,16 @@ func applyInputAnswerChanged(state *ahptypes.ChatState, a *ahptypes.ChatInputAns return ReduceOutcomeNoOp } +// ApplyActionToCanvas replaces the live canvas state or returns OutOfScope. +func ApplyActionToCanvas(state *ahptypes.CanvasState, action ahptypes.StateAction) ReduceOutcome { + a, ok := action.Value.(*ahptypes.CanvasStateChangedAction) + if !ok { + return ReduceOutcomeOutOfScope + } + *state = a.Canvas + return ReduceOutcomeApplied +} + // ─── Terminal Reducer ────────────────────────────────────────────────── // ApplyActionToTerminal applies action to the [ahptypes.TerminalState] diff --git a/clients/go/ahp/reducers_fixture_test.go b/clients/go/ahp/reducers_fixture_test.go index a1617d4b7..faf7905f9 100644 --- a/clients/go/ahp/reducers_fixture_test.go +++ b/clients/go/ahp/reducers_fixture_test.go @@ -145,6 +145,8 @@ func TestFixtureDrivenReducerParity(t *testing.T) { runFixture[ahptypes.SessionState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToSession) case "chat": runFixture[ahptypes.ChatState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToChat) + case "canvas": + runFixture[ahptypes.CanvasState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToCanvas) case "terminal": runFixture[ahptypes.TerminalState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToTerminal) case "changeset": diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index dc5e6f386..e8e705c50 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -49,6 +49,8 @@ const ( ActionTypeChatBackgroundWorkRemoved ActionType = "chat/backgroundWorkRemoved" ActionTypeChatMovableChanged ActionType = "chat/movableChanged" ActionTypeChatChangesetsChanged ActionType = "chat/changesetsChanged" + ActionTypeChatCanvasesChanged ActionType = "chat/canvasesChanged" + ActionTypeCanvasStateChanged ActionType = "canvas/stateChanged" ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet" ActionTypeChatWorkingDirectoryRemoved ActionType = "chat/workingDirectoryRemoved" ActionTypeSessionTitleChanged ActionType = "session/titleChanged" @@ -704,6 +706,27 @@ type ChatChangesetsChangedAction struct { Changesets []Changeset `json:"changesets,omitempty"` } +// The live canvas channels exposed by this chat changed. +// +// Replaces {@link ChatState.canvases | `state.canvases`} entirely. Set to +// `undefined` to clear the collection. +type ChatCanvasesChangedAction struct { + Type ActionType `json:"type"` + // New canvas channel references, or `undefined` to clear the collection. + Canvases []CanvasReference `json:"canvases,omitempty"` +} + +// The presentation state for this canvas changed. +// +// Replaces the subscribed canvas channel state entirely. Full-replacement +// semantics intentionally keep this early-development channel free to evolve +// without expanding the stable chat action surface. +type CanvasStateChangedAction struct { + Type ActionType `json:"type"` + // New authoritative canvas state. + Canvas CanvasState `json:"canvas"` +} + // Session title updated. Fired by the server when the title is auto-generated // from conversation, or dispatched by a client to rename a session. type SessionTitleChangedAction struct { @@ -1794,6 +1817,8 @@ func (*ChatBackgroundWorkSetAction) isStateAction() {} func (*ChatBackgroundWorkRemovedAction) isStateAction() {} func (*ChatMovableChangedAction) isStateAction() {} func (*ChatChangesetsChangedAction) isStateAction() {} +func (*ChatCanvasesChangedAction) isStateAction() {} +func (*CanvasStateChangedAction) isStateAction() {} func (*SessionTitleChangedAction) isStateAction() {} func (*ChatUsageAction) isStateAction() {} func (*ChatReasoningAction) isStateAction() {} @@ -2067,6 +2092,18 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "chat/canvasesChanged": + var value ChatCanvasesChangedAction + 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 { + return err + } + u.Value = &value case "session/titleChanged": var value SessionTitleChangedAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 4cc70c783..39a5438d9 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -1329,6 +1329,12 @@ type ChatState struct { // absent from {@link ChatSummary}; clients obtain it by subscribing to the // chat channel. BackgroundWork *[]BackgroundWork `json:"backgroundWork,omitempty"` + // Live canvases currently exposed by this chat. + // + // Entries intentionally contain only subscribable channel references. + // Clients subscribe to each resource for the experimental presentation + // state, including its current live source URL. + Canvases []CanvasReference `json:"canvases,omitempty"` // Completed turns Turns []Turn `json:"turns"` // Cursor for loading older completed turns into this chat state. @@ -1360,6 +1366,42 @@ type ChatState struct { Meta map[string]json.RawMessage `json:"_meta,omitempty"` } +// Stable reference to a subscribable canvas channel. +// +// Chat state intentionally carries only this reference so the experimental +// canvas presentation model can evolve without changing the stable chat +// channel shape. +type CanvasReference struct { + // Canvas channel URI. Subscribe to this resource for the full state. + Resource URI `json:"resource"` +} + +// Full state for one live canvas, returned when a client subscribes to its +// `ahp-canvas:` URI. +// +// 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. + InstanceId string `json:"instanceId"` + // Owning extension/provider identifier. + ExtensionId string `json:"extensionId"` + // Owning extension display name, when available. + ExtensionName *string `json:"extensionName,omitempty"` + // Provider-local canvas type identifier. + CanvasId string `json:"canvasId"` + // Provider-supplied title, when available. + Title *string `json:"title,omitempty"` + // Provider-supplied status text, when available. + Status *string `json:"status,omitempty"` + // Current absolute HTTP(S) source URL; absent when the live source is unavailable. + // Hosts MUST clear this field when the provider becomes unavailable. + // + // Source URLs MUST be redacted from diagnostic logs and MUST NOT be reused + // from persisted state after a provider or host restart. + Url *URI `json:"url,omitempty"` +} + // Lightweight catalog entry for a chat, carried in // {@link SessionState.chats | `SessionState.chats`}. The full conversation // lives in {@link ChatState}, which inlines (denormalizes) every field below. @@ -3597,7 +3639,7 @@ type ErrorInfo struct { // A point-in-time snapshot of a subscribed resource's state, returned by // `initialize`, `reconnect`, and `subscribe`. type Snapshot struct { - // The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) + // The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`) Resource URI `json:"resource"` // The current state of the resource State SnapshotState `json:"state"` @@ -6260,16 +6302,17 @@ func (o ChatOrigin) MarshalJSON() ([]byte, error) { } // SnapshotState is the state payload of a snapshot — root, session, -// chat, terminal, changeset, resource-watch, annotations, automation catalogue, +// chat, canvas, terminal, changeset, resource-watch, annotations, automation catalogue, // or automation-run state. The active // variant is chosen by which pointer field is non-nil; UnmarshalJSON probes // for required fields in the canonical order -// (automationRun → automations → session → chat → terminal → changeset → +// (automationRun → automations → session → chat → canvas → terminal → changeset → // resourceWatch → annotations → root). type SnapshotState struct { Root *RootState `json:"-"` Session *SessionState `json:"-"` Chat *ChatState `json:"-"` + Canvas *CanvasState `json:"-"` Terminal *TerminalState `json:"-"` Changeset *ChangesetState `json:"-"` ResourceWatch *ResourceWatchState `json:"-"` @@ -6289,6 +6332,8 @@ func (s SnapshotState) MarshalJSON() ([]byte, error) { return json.Marshal(s.Session) case s.Chat != nil: return json.Marshal(s.Chat) + case s.Canvas != nil: + return json.Marshal(s.Canvas) case s.Terminal != nil: return json.Marshal(s.Terminal) case s.Changeset != nil: @@ -6337,6 +6382,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { return err } s.Chat = &v + case containsAll(probe, "instanceId", "extensionId", "canvasId"): + var v CanvasState + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Canvas = &v case containsAll(probe, "content"): var v TerminalState if err := json.Unmarshal(data, &v); err != nil { diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 456d539c8..546b8583f 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -6,12 +6,13 @@ package ahptypes // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.9.0" +const ProtocolVersion = "0.10.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 4faee915d..3f1b9a894 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,6 +2,7 @@ "client": "go", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index bdf2a4881..63e469469 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,6 +2,7 @@ "client": "kotlin", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 8fd22a745..df6529ae6 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -19,10 +19,10 @@ import kotlinx.serialization.json.JsonPrimitive * A pure state reducer: `reduce(state, action)` returns the next state, with * no mutation of [state] and no side effects. * - * The companion top-level functions ([rootReducer], [sessionReducer], [chatReducer], + * The companion top-level functions ([rootReducer], [sessionReducer], [chatReducer], [canvasReducer], * [terminalReducer], [changesetReducer], [annotationsReducer], [resourceWatchReducer], * [automationReducer], and [automationRunReducer]) are the canonical implementations. - * The object instances on this interface ([RootReducer], [SessionReducer], [ChatReducer], + * The object instances on this interface ([RootReducer], [SessionReducer], [ChatReducer], [CanvasReducer], * [TerminalReducer], [ChangesetReducer], [AnnotationsReducer], [ResourceWatchReducer], * [AutomationReducer], and [AutomationRunReducer]) wrap them for use as values where an * instance is needed. @@ -49,6 +49,12 @@ public object ChatReducer : Reducer { chatReducer(state, action) } +/** Pure canvas reducer as a [Reducer] instance. */ +public object CanvasReducer : Reducer { + override fun reduce(state: CanvasState, action: StateAction): CanvasState = + canvasReducer(state, action) +} + /** Pure terminal reducer as a [Reducer] instance. Delegates to [terminalReducer]. */ public object TerminalReducer : Reducer { override fun reduce(state: TerminalState, action: StateAction): TerminalState = @@ -1046,6 +1052,9 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatChangesetsChanged -> state.copy(changesets = action.value.changesets) + is StateActionChatCanvasesChanged -> + state.copy(canvases = action.value.canvases) + is StateActionChatWorkingDirectorySet -> { val list = state.workingDirectories ?: emptyList() if (list.contains(action.value.directory)) { @@ -1638,6 +1647,12 @@ private data class CompleteCtx( ) +/** Pure reducer for live canvas state. */ +public fun canvasReducer(state: CanvasState, action: StateAction): CanvasState = when (action) { + is StateActionCanvasStateChanged -> action.value.canvas + else -> state +} + // ─── Terminal Reducer ─────────────────────────────────────────────────────── /** 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 3607b366b..a791d93b8 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 @@ -59,6 +59,8 @@ value class ActionType(val rawValue: String) { val CHAT_BACKGROUND_WORK_REMOVED: ActionType = ActionType("chat/backgroundWorkRemoved") 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 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") val SESSION_TITLE_CHANGED: ActionType = ActionType("session/titleChanged") @@ -778,6 +780,24 @@ data class ChatChangesetsChangedAction( val changesets: List? = null ) +@Serializable +data class ChatCanvasesChangedAction( + val type: ActionType, + /** + * New canvas channel references, or `undefined` to clear the collection. + */ + val canvases: List? = null +) + +@Serializable +data class CanvasStateChangedAction( + val type: ActionType, + /** + * New authoritative canvas state. + */ + val canvas: CanvasState +) + @Serializable data class SessionTitleChangedAction( val type: ActionType, @@ -1692,6 +1712,8 @@ sealed interface StateAction @JvmInline value class StateActionChatBackgroundWorkRemoved(val value: ChatBackgroundWorkRemovedAction) : 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 StateActionCanvasStateChanged(val value: CanvasStateChangedAction) : StateAction @JvmInline value class StateActionSessionTitleChanged(val value: SessionTitleChangedAction) : StateAction @JvmInline value class StateActionChatUsage(val value: ChatUsageAction) : StateAction @JvmInline value class StateActionChatReasoning(val value: ChatReasoningAction) : StateAction @@ -1810,6 +1832,8 @@ internal object StateActionSerializer : KSerializer { "chat/backgroundWorkRemoved" -> StateActionChatBackgroundWorkRemoved(input.json.decodeFromJsonElement(ChatBackgroundWorkRemovedAction.serializer(), element)) "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)) + "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)) "chat/reasoning" -> StateActionChatReasoning(input.json.decodeFromJsonElement(ChatReasoningAction.serializer(), element)) @@ -1921,6 +1945,8 @@ internal object StateActionSerializer : KSerializer { is StateActionChatBackgroundWorkRemoved -> output.json.encodeToJsonElement(ChatBackgroundWorkRemovedAction.serializer(), value.value) 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 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) is StateActionChatReasoning -> output.json.encodeToJsonElement(ChatReasoningAction.serializer(), value.value) 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 87ba426a8..b03b68ddd 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 @@ -1715,6 +1715,14 @@ data class ChatState( * chat channel. */ val backgroundWork: List? = null, + /** + * Live canvases currently exposed by this chat. + * + * Entries intentionally contain only subscribable channel references. + * Clients subscribe to each resource for the experimental presentation + * state, including its current live source URL. + */ + val canvases: List? = null, /** * Completed turns */ @@ -2074,6 +2082,50 @@ data class BackgroundSubagentWork( val chat: String ) +@Serializable +data class CanvasReference( + /** + * Canvas channel URI. Subscribe to this resource for the full state. + */ + val resource: String +) + +@Serializable +data class CanvasState( + /** + * Stable caller-supplied instance identifier. + */ + val instanceId: String, + /** + * Owning extension/provider identifier. + */ + val extensionId: String, + /** + * Owning extension display name, when available. + */ + val extensionName: String? = null, + /** + * Provider-local canvas type identifier. + */ + val canvasId: String, + /** + * Provider-supplied title, when available. + */ + val title: String? = null, + /** + * Provider-supplied status text, when available. + */ + val status: String? = null, + /** + * Current absolute HTTP(S) source URL; absent when the live source is unavailable. + * Hosts MUST clear this field when the provider becomes unavailable. + * + * Source URLs MUST be redacted from diagnostic logs and MUST NOT be reused + * from persisted state after a provider or host restart. + */ + val url: String? = null +) + @Serializable data class SessionChatInputRequest( /** @@ -5046,7 +5098,7 @@ data class ErrorInfo( @Serializable data class Snapshot( /** - * The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) + * The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`) */ val resource: String, /** @@ -7423,6 +7475,7 @@ sealed interface SnapshotState { @JvmInline value class Root(val value: RootState) : SnapshotState @JvmInline value class Session(val value: SessionState) : SnapshotState @JvmInline value class Chat(val value: ChatState) : SnapshotState + @JvmInline value class Canvas(val value: CanvasState) : SnapshotState @JvmInline value class Terminal(val value: TerminalState) : SnapshotState @JvmInline value class Changeset(val value: ChangesetState) : SnapshotState @JvmInline value class ResourceWatch(val value: ResourceWatchState) : SnapshotState @@ -7457,6 +7510,8 @@ internal object SnapshotStateSerializer : KSerializer { SnapshotState.Automations(input.json.decodeFromJsonElement(AutomationState.serializer(), element)) obj.containsKey("lifecycle") -> SnapshotState.Session(input.json.decodeFromJsonElement(SessionState.serializer(), element)) obj.containsKey("turns") -> SnapshotState.Chat(input.json.decodeFromJsonElement(ChatState.serializer(), element)) + obj.containsKey("instanceId") && obj.containsKey("extensionId") && obj.containsKey("canvasId") -> + SnapshotState.Canvas(input.json.decodeFromJsonElement(CanvasState.serializer(), element)) obj.containsKey("status") && obj.containsKey("files") -> SnapshotState.Changeset(input.json.decodeFromJsonElement(ChangesetState.serializer(), element)) obj.containsKey("root") && obj.containsKey("recursive") -> @@ -7476,6 +7531,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Root -> output.json.encodeToJsonElement(RootState.serializer(), value.value) is SnapshotState.Session -> output.json.encodeToJsonElement(SessionState.serializer(), value.value) is SnapshotState.Chat -> output.json.encodeToJsonElement(ChatState.serializer(), value.value) + is SnapshotState.Canvas -> output.json.encodeToJsonElement(CanvasState.serializer(), value.value) is SnapshotState.Terminal -> output.json.encodeToJsonElement(TerminalState.serializer(), value.value) is SnapshotState.Changeset -> output.json.encodeToJsonElement(ChangesetState.serializer(), value.value) is SnapshotState.ResourceWatch -> output.json.encodeToJsonElement(ResourceWatchState.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index 31c2b348c..d4da30550 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.9.0" +public const val PROTOCOL_VERSION: String = "0.10.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,6 +16,7 @@ public const val PROTOCOL_VERSION: String = "0.9.0" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt index c3f4675eb..66e7b8a9a 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt @@ -1,6 +1,7 @@ package com.microsoft.agenthostprotocol import com.microsoft.agenthostprotocol.generated.ChatState +import com.microsoft.agenthostprotocol.generated.CanvasState import com.microsoft.agenthostprotocol.generated.ChangesetState import com.microsoft.agenthostprotocol.generated.AnnotationsState import com.microsoft.agenthostprotocol.generated.AutomationState @@ -152,6 +153,18 @@ class FixtureDrivenReducerTest { }, ) + "canvas" -> compareFixture( + file = file, + initial = initial, + expected = expected, + serializer = CanvasState.serializer(), + run = { state -> + var s = state + for (action in actions) s = canvasReducer(s, action) + s + }, + ) + "terminal" -> compareFixture( file = file, initial = initial, diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 776a1ed7e..003dbe9d1 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -15,16 +15,17 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, - BackgroundWork, ChangesSummary, Changeset, ChangesetFile, ChangesetOperation, - ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, - ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, ConfirmationOption, - ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, - FileEditCollection, McpAuthRequirement, McpServerState, Message, ModelSelection, - PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, - TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, - ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, - ToolResultContent, Turn, UsageInfo, + BackgroundWork, CanvasReference, CanvasState, ChangesSummary, Changeset, ChangesetFile, + ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, + ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, + ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, + ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, Message, + ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, SessionInputRequest, + SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallCancellationReason, + ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, + ToolDefinition, ToolInput, ToolResultContent, Turn, UsageInfo, }; + // ─── ActionType ────────────────────────────────────────────────────── /// Discriminant values for all state actions. @@ -60,6 +61,8 @@ pub enum ActionType { ChatBackgroundWorkRemoved, ChatMovableChanged, ChatChangesetsChanged, + ChatCanvasesChanged, + CanvasStateChanged, ChatWorkingDirectorySet, ChatWorkingDirectoryRemoved, SessionTitleChanged, @@ -183,6 +186,8 @@ 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::CanvasStateChanged => serializer.serialize_str("canvas/stateChanged"), Self::ChatWorkingDirectorySet => serializer.serialize_str("chat/workingDirectorySet"), Self::ChatWorkingDirectoryRemoved => { serializer.serialize_str("chat/workingDirectoryRemoved") @@ -352,6 +357,8 @@ impl<'de> serde::Deserialize<'de> for ActionType { "chat/backgroundWorkRemoved" => Self::ChatBackgroundWorkRemoved, "chat/movableChanged" => Self::ChatMovableChanged, "chat/changesetsChanged" => Self::ChatChangesetsChanged, + "chat/canvasesChanged" => Self::ChatCanvasesChanged, + "canvas/stateChanged" => Self::CanvasStateChanged, "chat/workingDirectorySet" => Self::ChatWorkingDirectorySet, "chat/workingDirectoryRemoved" => Self::ChatWorkingDirectoryRemoved, "session/titleChanged" => Self::SessionTitleChanged, @@ -1133,6 +1140,30 @@ pub struct ChatChangesetsChangedAction { pub changesets: Option>, } +/// The live canvas channels exposed by this chat changed. +/// +/// Replaces {@link ChatState.canvases | `state.canvases`} entirely. Set to +/// `undefined` to clear the collection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ChatCanvasesChangedAction { + /// New canvas channel references, or `undefined` to clear the collection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option>, +} + +/// The presentation state for this canvas changed. +/// +/// Replaces the subscribed canvas channel state entirely. Full-replacement +/// semantics intentionally keep this early-development channel free to evolve +/// without expanding the stable chat action surface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasStateChangedAction { + /// New authoritative canvas state. + pub canvas: CanvasState, +} + /// Session title updated. Fired by the server when the title is auto-generated /// from conversation, or dispatched by a client to rename a session. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -2389,6 +2420,10 @@ pub enum StateAction { ChatMovableChanged(ChatMovableChangedAction), #[serde(rename = "chat/changesetsChanged")] ChatChangesetsChanged(ChatChangesetsChangedAction), + #[serde(rename = "chat/canvasesChanged")] + ChatCanvasesChanged(ChatCanvasesChangedAction), + #[serde(rename = "canvas/stateChanged")] + CanvasStateChanged(CanvasStateChangedAction), #[serde(rename = "session/titleChanged")] SessionTitleChanged(SessionTitleChangedAction), #[serde(rename = "chat/usage")] diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 06fc17955..6cc27fe5b 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -1992,6 +1992,13 @@ pub struct ChatState { /// chat channel. #[serde(default, skip_serializing_if = "Option::is_none")] pub background_work: Option>, + /// Live canvases currently exposed by this chat. + /// + /// Entries intentionally contain only subscribable channel references. + /// Clients subscribe to each resource for the experimental presentation + /// state, including its current live source URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvases: Option>, /// Completed turns pub turns: Vec, /// Cursor for loading older completed turns into this chat state. @@ -2029,6 +2036,50 @@ pub struct ChatState { pub meta: Option, } +/// Stable reference to a subscribable canvas channel. +/// +/// Chat state intentionally carries only this reference so the experimental +/// canvas presentation model can evolve without changing the stable chat +/// channel shape. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReference { + /// Canvas channel URI. Subscribe to this resource for the full state. + pub resource: Uri, +} + +/// Full state for one live canvas, returned when a client subscribes to its +/// `ahp-canvas:` URI. +/// +/// The client already knows the subscribed resource, so the state does not +/// redundantly carry its channel URI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasState { + /// Stable caller-supplied instance identifier. + pub instance_id: String, + /// Owning extension/provider identifier. + pub extension_id: String, + /// Owning extension display name, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Provider-local canvas type identifier. + pub canvas_id: String, + /// Provider-supplied title, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Provider-supplied status text, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Current absolute HTTP(S) source URL; absent when the live source is unavailable. + /// Hosts MUST clear this field when the provider becomes unavailable. + /// + /// Source URLs MUST be redacted from diagnostic logs and MUST NOT be reused + /// from persisted state after a provider or host restart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + /// Lightweight catalog entry for a chat, carried in /// {@link SessionState.chats | `SessionState.chats`}. The full conversation /// lives in {@link ChatState}, which inlines (denormalizes) every field below. @@ -5101,7 +5152,7 @@ pub struct ErrorInfo { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Snapshot { - /// The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) + /// The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`) pub resource: Uri, /// The current state of the resource pub state: SnapshotState, @@ -6501,7 +6552,8 @@ pub enum AutomationRunLifecycle { /// The state payload of a snapshot. /// /// Deserialized by trying session first (has required `lifecycle`), then -/// chat (has required `turns`), then terminal (has required `content`), +/// chat (has required `turns`), then canvas (has required instance/provider/type +/// identifiers), then terminal (has required `content`), /// then changeset (has required `status` and `files`), then resource-watch /// (has required `root` and `recursive`), then annotations (has required /// `annotations`), then the automation catalogue (has required @@ -6511,6 +6563,7 @@ pub enum AutomationRunLifecycle { pub enum SnapshotState { Session(Box), Chat(Box), + Canvas(Box), Terminal(Box), Changeset(Box), ResourceWatch(Box), diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 15cc81d3e..042f44922 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.9.0"; +pub const PROTOCOL_VERSION: &str = "0.10.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,5 +13,6 @@ pub const PROTOCOL_VERSION: &str = "0.9.0"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = - &["0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1", +]; diff --git a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs index c581bd26d..fa1481b53 100644 --- a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs @@ -39,14 +39,15 @@ use std::collections::HashMap; use ahp_types::actions::ActionEnvelope; use ahp_types::common::ROOT_RESOURCE_URI; use ahp_types::state::{ - AnnotationsState, AutomationEntry, AutomationRunState, AutomationState, ChangesetState, - ChatState, ResourceWatchState, RootState, SessionState, SnapshotState, TerminalState, + AnnotationsState, AutomationEntry, AutomationRunState, AutomationState, CanvasState, + ChangesetState, ChatState, ResourceWatchState, RootState, SessionState, SnapshotState, + TerminalState, }; use crate::hosts::{HostId, HostSubscriptionEvent}; use crate::reducers::{ - apply_action_to_automation, apply_action_to_automation_run, apply_action_to_chat, - apply_action_to_root, apply_action_to_session, apply_action_to_terminal, + apply_action_to_automation, apply_action_to_automation_run, apply_action_to_canvas, + apply_action_to_chat, apply_action_to_root, apply_action_to_session, apply_action_to_terminal, }; use crate::SubscriptionEvent; @@ -93,6 +94,7 @@ pub struct MultiHostStateMirror { root_states: HashMap, sessions: HashMap, chats: HashMap, + canvases: HashMap, terminals: HashMap, changesets: HashMap, annotations: HashMap, @@ -123,6 +125,11 @@ impl MultiHostStateMirror { &self.chats } + /// Borrow live canvases keyed by `(host_id, uri)`. + pub fn canvases(&self) -> &HashMap { + &self.canvases + } + /// Borrow the terminal states map keyed by `(host_id, uri)`. pub fn terminals(&self) -> &HashMap { &self.terminals @@ -207,6 +214,10 @@ impl MultiHostStateMirror { apply_action_to_chat(chat, &envelope.action); return; } + if let Some(canvas) = self.canvases.get_mut(&key) { + apply_action_to_canvas(canvas, &envelope.action); + return; + } if let Some(terminal) = self.terminals.get_mut(&key) { apply_action_to_terminal(terminal, &envelope.action); return; @@ -236,6 +247,9 @@ impl MultiHostStateMirror { SnapshotState::Chat(state) => { self.chats.insert(key, state.as_ref().clone()); } + SnapshotState::Canvas(state) => { + self.canvases.insert(key, state.as_ref().clone()); + } SnapshotState::Terminal(state) => { self.terminals.insert(key, state.as_ref().clone()); } @@ -266,6 +280,7 @@ impl MultiHostStateMirror { self.root_states.remove(host); self.sessions.retain(|key, _| &key.host_id != host); self.chats.retain(|key, _| &key.host_id != host); + self.canvases.retain(|key, _| &key.host_id != host); self.terminals.retain(|key, _| &key.host_id != host); self.changesets.retain(|key, _| &key.host_id != host); self.annotations.retain(|key, _| &key.host_id != host); @@ -280,6 +295,7 @@ impl MultiHostStateMirror { self.root_states.clear(); self.sessions.clear(); self.chats.clear(); + self.canvases.clear(); self.terminals.clear(); self.changesets.clear(); self.annotations.clear(); diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 76602c966..a7e24f307 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -57,7 +57,7 @@ use ahp_types::actions::{ ChatTurnStartedAction, StateAction, }; use ahp_types::state::{ - ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, BackgroundWork, + ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, BackgroundWork, CanvasState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, CustomizationEnablement, ErrorResponsePart, InputRequestResponsePart, McpServerCustomization, McpServerStartingState, @@ -1190,6 +1190,10 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu state.changesets = a.changesets.clone(); ReduceOutcome::Applied } + StateAction::ChatCanvasesChanged(a) => { + state.canvases = a.canvases.clone(); + ReduceOutcome::Applied + } StateAction::ChatWorkingDirectorySet(a) => { let list = state.working_directories.get_or_insert_with(Vec::new); if list.contains(&a.directory) { @@ -1870,6 +1874,17 @@ fn apply_input_answer_changed( ReduceOutcome::Applied } +/// Apply a [`StateAction`] to a [`CanvasState`] in place. +pub fn apply_action_to_canvas(state: &mut CanvasState, action: &StateAction) -> ReduceOutcome { + match action { + StateAction::CanvasStateChanged(a) => { + *state = a.canvas.clone(); + ReduceOutcome::Applied + } + _ => ReduceOutcome::OutOfScope, + } +} + // ─── Terminal Reducer ───────────────────────────────────────────────── /// Apply a [`StateAction`] to a [`TerminalState`] in place. @@ -2296,6 +2311,7 @@ mod tests { interactivity: None, working_directories: None, changesets: None, + canvases: None, turns: Vec::new(), turns_next_cursor: None, active_turn: None, @@ -2714,6 +2730,14 @@ mod tests { &file_name, description, ), + "canvas" => run_fixture::( + initial, + expected, + &parsed_actions, + apply_action_to_canvas, + &file_name, + description, + ), "terminal" => run_fixture::( initial, expected, diff --git a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs index aa7341e59..aac34d296 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -11,13 +11,13 @@ use ahp::hosts::{HostId, HostSubscriptionEvent}; use ahp::{HostedResourceKey, MultiHostStateMirror, SubscriptionEvent}; use ahp_types::actions::{ - ActionEnvelope, RootActiveSessionsChangedAction, RootAgentsChangedAction, - SessionTitleChangedAction, StateAction, + ActionEnvelope, CanvasStateChangedAction, RootActiveSessionsChangedAction, + RootAgentsChangedAction, SessionTitleChangedAction, StateAction, }; use ahp_types::common::ROOT_RESOURCE_URI; use ahp_types::state::{ - AgentInfo, RootState, SessionLifecycle, SessionState, SessionStatus, SessionSummary, Snapshot, - SnapshotState, + AgentInfo, CanvasState, RootState, SessionLifecycle, SessionState, SessionStatus, + SessionSummary, Snapshot, SnapshotState, }; fn agent(provider: &str) -> AgentInfo { @@ -130,6 +130,61 @@ fn root_agents_changed_envelope(agents: Vec, server_seq: u64) -> Acti } } +#[test] +fn canvases_are_reduced_and_reset_per_host() { + let mut mirror = MultiHostStateMirror::new(); + let uri = "ahp-canvas:/preview"; + let alpha = HostId::new("alpha"); + let beta = HostId::new("beta"); + let initial = CanvasState { + instance_id: "preview".into(), + extension_id: "project:preview".into(), + extension_name: None, + canvas_id: "preview".into(), + title: None, + status: None, + url: Some("https://example.test/original".into()), + }; + let snapshot = Snapshot { + resource: uri.into(), + state: SnapshotState::Canvas(Box::new(initial.clone())), + from_seq: 1, + }; + mirror.apply_snapshot(&alpha, &snapshot); + mirror.apply_snapshot(&beta, &snapshot); + mirror.apply_envelope( + &alpha, + &ActionEnvelope { + channel: uri.into(), + action: StateAction::CanvasStateChanged(CanvasStateChangedAction { + canvas: CanvasState { + url: None, + ..initial.clone() + }, + }), + server_seq: 2, + origin: None, + rejection_reason: None, + }, + ); + let alpha_key = HostedResourceKey::new(alpha.clone(), uri); + let beta_key = HostedResourceKey::new(beta, uri); + let targeted = ( + mirror.canvases()[&alpha_key].url.clone(), + mirror.canvases()[&beta_key].url.clone(), + ); + mirror.reset_host(&alpha); + let reset = ( + mirror.canvases().contains_key(&alpha_key), + mirror.canvases().contains_key(&beta_key), + ); + mirror.reset(); + assert_eq!( + (targeted, reset, mirror.canvases().is_empty()), + ((None, initial.url), (false, true), true), + ); +} + #[test] fn root_states_are_isolated_per_host() { let mut mirror = MultiHostStateMirror::new(); diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index b93f3893f..a756c2ac8 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,6 +2,7 @@ "client": "rust", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 991813b78..c2142c970 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -36,6 +36,8 @@ public enum ActionType: Codable, Sendable, Equatable { case chatBackgroundWorkRemoved case chatMovableChanged case chatChangesetsChanged + case chatCanvasesChanged + case canvasStateChanged case chatWorkingDirectorySet case chatWorkingDirectoryRemoved case sessionTitleChanged @@ -146,6 +148,8 @@ public enum ActionType: Codable, Sendable, Equatable { case "chat/backgroundWorkRemoved": self = .chatBackgroundWorkRemoved case "chat/movableChanged": self = .chatMovableChanged case "chat/changesetsChanged": self = .chatChangesetsChanged + case "chat/canvasesChanged": self = .chatCanvasesChanged + case "canvas/stateChanged": self = .canvasStateChanged case "chat/workingDirectorySet": self = .chatWorkingDirectorySet case "chat/workingDirectoryRemoved": self = .chatWorkingDirectoryRemoved case "session/titleChanged": self = .sessionTitleChanged @@ -256,6 +260,8 @@ public enum ActionType: Codable, Sendable, Equatable { case .chatBackgroundWorkRemoved: try container.encode("chat/backgroundWorkRemoved") case .chatMovableChanged: try container.encode("chat/movableChanged") case .chatChangesetsChanged: try container.encode("chat/changesetsChanged") + case .chatCanvasesChanged: try container.encode("chat/canvasesChanged") + case .canvasStateChanged: try container.encode("canvas/stateChanged") case .chatWorkingDirectorySet: try container.encode("chat/workingDirectorySet") case .chatWorkingDirectoryRemoved: try container.encode("chat/workingDirectoryRemoved") case .sessionTitleChanged: try container.encode("session/titleChanged") @@ -1267,6 +1273,34 @@ public struct ChatChangesetsChangedAction: Codable, Sendable { } } +public struct ChatCanvasesChangedAction: Codable, Sendable { + public var type: ActionType + /// New canvas channel references, or `undefined` to clear the collection. + public var canvases: [CanvasReference]? + + public init( + type: ActionType, + canvases: [CanvasReference]? = nil + ) { + self.type = type + self.canvases = canvases + } +} + +public struct CanvasStateChangedAction: Codable, Sendable { + public var type: ActionType + /// New authoritative canvas state. + public var canvas: CanvasState + + public init( + type: ActionType, + canvas: CanvasState + ) { + self.type = type + self.canvas = canvas + } +} + public struct SessionTitleChangedAction: Codable, Sendable { public var type: ActionType /// New title @@ -2556,6 +2590,8 @@ public enum StateAction: Codable, Sendable { case chatBackgroundWorkRemoved(ChatBackgroundWorkRemovedAction) case chatMovableChanged(ChatMovableChangedAction) case chatChangesetsChanged(ChatChangesetsChangedAction) + case chatCanvasesChanged(ChatCanvasesChangedAction) + case canvasStateChanged(CanvasStateChangedAction) case sessionTitleChanged(SessionTitleChangedAction) case chatUsage(ChatUsageAction) case chatReasoning(ChatReasoningAction) @@ -2701,6 +2737,10 @@ public enum StateAction: Codable, Sendable { self = .chatMovableChanged(try ChatMovableChangedAction(from: decoder)) case "chat/changesetsChanged": self = .chatChangesetsChanged(try ChatChangesetsChangedAction(from: decoder)) + case "chat/canvasesChanged": + self = .chatCanvasesChanged(try ChatCanvasesChangedAction(from: decoder)) + case "canvas/stateChanged": + self = .canvasStateChanged(try CanvasStateChangedAction(from: decoder)) case "session/titleChanged": self = .sessionTitleChanged(try SessionTitleChangedAction(from: decoder)) case "chat/usage": @@ -2884,6 +2924,8 @@ public enum StateAction: Codable, Sendable { case .chatBackgroundWorkRemoved(let v): try v.encode(to: encoder) 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 .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) case .chatReasoning(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index fd43ba1af..9f4b66b72 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -1725,6 +1725,12 @@ public struct ChatState: Codable, Sendable { /// absent from {@link ChatSummary}; clients obtain it by subscribing to the /// chat channel. public var backgroundWork: [BackgroundWork]? + /// Live canvases currently exposed by this chat. + /// + /// Entries intentionally contain only subscribable channel references. + /// Clients subscribe to each resource for the experimental presentation + /// state, including its current live source URL. + public var canvases: [CanvasReference]? /// Completed turns public var turns: [Turn] /// Cursor for loading older completed turns into this chat state. @@ -1768,6 +1774,7 @@ public struct ChatState: Codable, Sendable { case workingDirectories case changesets case backgroundWork + case canvases case turns case turnsNextCursor case activeTurn @@ -1790,6 +1797,7 @@ public struct ChatState: Codable, Sendable { workingDirectories: [String]? = nil, changesets: [Changeset]? = nil, backgroundWork: [BackgroundWork]? = nil, + canvases: [CanvasReference]? = nil, turns: [Turn], turnsNextCursor: String? = nil, activeTurn: ActiveTurn? = nil, @@ -1810,6 +1818,7 @@ public struct ChatState: Codable, Sendable { self.workingDirectories = workingDirectories self.changesets = changesets self.backgroundWork = backgroundWork + self.canvases = canvases self.turns = turns self.turnsNextCursor = turnsNextCursor self.activeTurn = activeTurn @@ -5917,7 +5926,7 @@ public struct ErrorInfo: Codable, Sendable { } public struct Snapshot: Codable, Sendable { - /// The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) + /// The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`) public var resource: String /// The current state of the resource public var state: SnapshotState @@ -5935,6 +5944,56 @@ public struct Snapshot: Codable, Sendable { } } +public struct CanvasReference: Codable, Sendable { + /// Canvas channel URI. Subscribe to this resource for the full state. + public var resource: String + + public init( + resource: String + ) { + self.resource = resource + } +} + +public struct CanvasState: Codable, Sendable { + /// Stable caller-supplied instance identifier. + public var instanceId: String + /// Owning extension/provider identifier. + public var extensionId: String + /// Owning extension display name, when available. + public var extensionName: String? + /// Provider-local canvas type identifier. + public var canvasId: String + /// Provider-supplied title, when available. + public var title: String? + /// Provider-supplied status text, when available. + public var status: String? + /// Current absolute HTTP(S) source URL; absent when the live source is unavailable. + /// Hosts MUST clear this field when the provider becomes unavailable. + /// + /// Source URLs MUST be redacted from diagnostic logs and MUST NOT be reused + /// from persisted state after a provider or host restart. + public var url: String? + + public init( + instanceId: String, + extensionId: String, + extensionName: String? = nil, + canvasId: String, + title: String? = nil, + status: String? = nil, + url: String? = nil + ) { + self.instanceId = instanceId + self.extensionId = extensionId + self.extensionName = extensionName + self.canvasId = canvasId + self.title = title + self.status = status + self.url = url + } +} + public struct Changeset: Codable, Sendable { /// Human-readable label, e.g. `"Uncommitted Changes"`. public var label: String @@ -8216,6 +8275,7 @@ public enum SnapshotState: Codable, Sendable { case root(RootState) case session(SessionState) case chat(ChatState) + case canvas(CanvasState) case terminal(TerminalState) case changeset(ChangesetState) case resourceWatch(ResourceWatchState) @@ -8226,12 +8286,15 @@ public enum SnapshotState: Codable, Sendable { public init(from decoder: Decoder) throws { // Try the most distinctive shapes first. SessionState has required // `lifecycle` / `activeClients` / `chats`; ChatState has required - // `turns`; the remaining variants follow, with RootState as the + // `turns`; CanvasState has required instance/provider/type identifiers; + // the remaining variants follow, with RootState as the // catch-all. if let session = try? SessionState(from: decoder) { self = .session(session) } else if let chat = try? ChatState(from: decoder) { self = .chat(chat) + } else if let canvas = try? CanvasState(from: decoder) { + self = .canvas(canvas) } else if let terminal = try? TerminalState(from: decoder) { self = .terminal(terminal) } else if let changeset = try? ChangesetState(from: decoder) { @@ -8254,6 +8317,7 @@ public enum SnapshotState: Codable, Sendable { case .root(let state): try state.encode(to: encoder) case .session(let state): try state.encode(to: encoder) case .chat(let state): try state.encode(to: encoder) + case .canvas(let state): try state.encode(to: encoder) case .terminal(let state): try state.encode(to: encoder) case .changeset(let state): try state.encode(to: encoder) case .resourceWatch(let state): try state.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index b85278501..35825e387 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.9.0" +public let PROTOCOL_VERSION: String = "0.10.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first. The first entry equals @@ -13,6 +13,7 @@ public let PROTOCOL_VERSION: String = "0.9.0" /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 6f4ef620c..328bbdc54 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -242,6 +242,11 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { next.changesets = a.changesets return next + case .chatCanvasesChanged(let a): + var next = state + next.canvases = a.canvases + return next + case .chatWorkingDirectorySet(let a): if (state.workingDirectories ?? []).contains(a.directory) { return state } var next = state @@ -1306,6 +1311,14 @@ private func updateResponsePart( return next } +/// Pure reducer for live canvas state. +public func canvasReducer(state: CanvasState, action: StateAction) -> CanvasState { + guard case .canvasStateChanged(let a) = action else { + return state + } + return a.canvas +} + // MARK: - Terminal Reducer /// Pure reducer for terminal state. Handles all terminal-scoped actions. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift index 271684937..eb410021d 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift @@ -14,6 +14,7 @@ public actor AHPStateMirror { public private(set) var rootState: RootState = RootState(agents: []) public private(set) var sessions: [String: SessionState] = [:] public private(set) var chats: [String: ChatState] = [:] + public private(set) var canvases: [String: CanvasState] = [:] public private(set) var terminals: [String: TerminalState] = [:] public private(set) var changesets: [String: ChangesetState] = [:] public private(set) var annotations: [String: AnnotationsState] = [:] @@ -47,6 +48,10 @@ public actor AHPStateMirror { chats[channel] = chat return } + if channel.hasPrefix("ahp-canvas:"), let canvas = canvases[channel] { + canvases[channel] = canvasReducer(state: canvas, action: action) + return + } if channel.hasPrefix("ahp-terminal:"), var terminal = terminals[channel] { terminal = terminalReducer(state: terminal, action: action) terminals[channel] = terminal @@ -89,6 +94,8 @@ public actor AHPStateMirror { sessions[snapshot.resource] = state case .chat(let state): chats[snapshot.resource] = state + case .canvas(let state): + canvases[snapshot.resource] = state case .terminal(let state): terminals[snapshot.resource] = state case .changeset(let state): @@ -118,6 +125,7 @@ public actor AHPStateMirror { rootState = RootState(agents: []) sessions.removeAll() chats.removeAll() + canvases.removeAll() terminals.removeAll() changesets.removeAll() annotations.removeAll() diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift index e08fd4015..289728ab6 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift @@ -45,6 +45,7 @@ public actor MultiHostStateMirror { public private(set) var rootStates: [HostId: RootState] = [:] public private(set) var sessions: [HostedResourceKey: SessionState] = [:] public private(set) var chats: [HostedResourceKey: ChatState] = [:] + public private(set) var canvases: [HostedResourceKey: CanvasState] = [:] public private(set) var terminals: [HostedResourceKey: TerminalState] = [:] public private(set) var changesets: [HostedResourceKey: ChangesetState] = [:] public private(set) var annotations: [HostedResourceKey: AnnotationsState] = [:] @@ -87,6 +88,10 @@ public actor MultiHostStateMirror { chats[key] = chat return } + if channel.hasPrefix("ahp-canvas:"), let canvas = canvases[key] { + canvases[key] = canvasReducer(state: canvas, action: action) + return + } if channel.hasPrefix("ahp-terminal:"), var terminal = terminals[key] { terminal = terminalReducer(state: terminal, action: action) terminals[key] = terminal @@ -133,6 +138,8 @@ public actor MultiHostStateMirror { sessions[key] = state case .chat(let state): chats[key] = state + case .canvas(let state): + canvases[key] = state case .terminal(let state): terminals[key] = state case .changeset(let state): @@ -153,6 +160,7 @@ public actor MultiHostStateMirror { rootStates.removeValue(forKey: host) sessions = sessions.filter { $0.key.hostId != host } chats = chats.filter { $0.key.hostId != host } + canvases = canvases.filter { $0.key.hostId != host } terminals = terminals.filter { $0.key.hostId != host } changesets = changesets.filter { $0.key.hostId != host } annotations = annotations.filter { $0.key.hostId != host } @@ -167,6 +175,7 @@ public actor MultiHostStateMirror { rootStates.removeAll() sessions.removeAll() chats.removeAll() + canvases.removeAll() terminals.removeAll() changesets.removeAll() annotations.removeAll() diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift index 7c6d46aab..d71d94fb7 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPStateMirrorTests.swift @@ -6,6 +6,38 @@ import AgentHostProtocol final class AHPStateMirrorTests: XCTestCase { + func testCanvasSourcesAreReplacedClearedAndReset() async { + let mirror = AHPStateMirror() + let resource = "ahp-canvas:/preview" + let initial = CanvasState( + instanceId: "preview", extensionId: "project:preview", canvasId: "preview", + url: "https://example.test/old" + ) + await mirror.applySnapshot(Snapshot(resource: resource, state: .canvas(initial), fromSeq: 1)) + var replacement = initial + replacement.url = "https://example.test/new" + await mirror.apply(ActionEnvelope( + channel: resource, + action: .canvasStateChanged(CanvasStateChangedAction(type: .canvasStateChanged, canvas: replacement)), + serverSeq: 2 + )) + let replaced = await mirror.canvases + replacement.url = nil + await mirror.apply(ActionEnvelope( + channel: resource, + action: .canvasStateChanged(CanvasStateChangedAction(type: .canvasStateChanged, canvas: replacement)), + serverSeq: 3 + )) + let unavailable = await mirror.canvases + await mirror.reset() + let remaining = await mirror.canvases + XCTAssertEqual( + [replaced[resource]?.url, unavailable[resource]?.instanceId, unavailable[resource]?.url], + ["https://example.test/new", "preview", nil] + ) + XCTAssertTrue(remaining.isEmpty) + } + func testApplySnapshotSeedsRootState() async { let mirror = AHPStateMirror() let agents = [ diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift index 218f9adfc..311d2a3cd 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostStateMirrorTests.swift @@ -6,6 +6,37 @@ import AgentHostProtocol final class MultiHostStateMirrorTests: XCTestCase { + func testCanvasUpdatesAndResetAreIsolatedPerHost() async { + let mirror = MultiHostStateMirror() + let resource = "ahp-canvas:/preview" + let initial = CanvasState( + instanceId: "preview", extensionId: "project:preview", canvasId: "preview", + url: "https://example.test/original" + ) + let snapshot = Snapshot(resource: resource, state: .canvas(initial), fromSeq: 1) + await mirror.applySnapshot(host: "alpha", snapshot: snapshot) + await mirror.applySnapshot(host: "beta", snapshot: snapshot) + var unavailable = initial + unavailable.url = nil + await mirror.apply(host: "alpha", envelope: ActionEnvelope( + channel: resource, + action: .canvasStateChanged(CanvasStateChangedAction(type: .canvasStateChanged, canvas: unavailable)), + serverSeq: 2 + )) + let targeted = await mirror.canvases + await mirror.reset(host: "alpha") + let afterReset = await mirror.canvases + await mirror.reset() + let remaining = await mirror.canvases + let alphaKey = HostedResourceKey(hostId: "alpha", uri: resource) + let betaKey = HostedResourceKey(hostId: "beta", uri: resource) + XCTAssertEqual( + [targeted[alphaKey]?.url, targeted[betaKey]?.url, afterReset[alphaKey]?.instanceId, afterReset[betaKey]?.url], + [nil, "https://example.test/original", nil, "https://example.test/original"] + ) + XCTAssertTrue(remaining.isEmpty) + } + // MARK: - root_states_are_isolated_per_host func testRootStatesAreIsolatedPerHost() async { diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift index 100ef243c..2b216d639 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift @@ -192,6 +192,10 @@ final class FixtureDrivenReducerTests: XCTestCase { try compareFixture(file: file, fixture: fixture, stateType: ChatState.self) { state in actions.reduce(state) { chatReducer(state: $0, action: $1) } } + case "canvas": + try compareFixture(file: file, fixture: fixture, stateType: CanvasState.self) { state in + actions.reduce(state) { canvasReducer(state: $0, action: $1) } + } case "annotations": try compareFixture(file: file, fixture: fixture, stateType: AnnotationsState.self) { state in actions.reduce(state) { annotationsReducer(state: $0, action: $1) } diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index e5693c0d4..485b6ec3d 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,6 +2,7 @@ "client": "swift", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index 1925c9293..c7d90aa5d 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,6 +2,7 @@ "client": "typescript", "packageVersion": "0.9.0", "supportedProtocolVersions": [ + "0.10.0", "0.9.0", "0.8.0", "0.7.0", diff --git a/clients/typescript/src/client/canvas-state.ts b/clients/typescript/src/client/canvas-state.ts new file mode 100644 index 000000000..b9a90da8e --- /dev/null +++ b/clients/typescript/src/client/canvas-state.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Snapshot } from '../types/common/state.js'; +import type { CanvasState } from '../types/channels-canvas/state.js'; + +/** Identifies the canvas variant of an untagged snapshot state. */ +export function isCanvasState(state: Snapshot['state']): state is CanvasState { + if (typeof state !== 'object' || state === null + || !('instanceId' in state) || typeof state.instanceId !== 'string' + || !('extensionId' in state) || typeof state.extensionId !== 'string' + || !('canvasId' in state) || typeof state.canvasId !== 'string') { + return false; + } + return (state.extensionName === undefined || typeof state.extensionName === 'string') + && (state.title === undefined || typeof state.title === 'string') + && (state.status === undefined || typeof state.status === 'string') + && (state.url === undefined || typeof state.url === 'string'); +} diff --git a/clients/typescript/src/client/hosts/state-mirror.ts b/clients/typescript/src/client/hosts/state-mirror.ts index 50ff617bc..6e2c6408e 100644 --- a/clients/typescript/src/client/hosts/state-mirror.ts +++ b/clients/typescript/src/client/hosts/state-mirror.ts @@ -28,7 +28,7 @@ * @module client/hosts/state-mirror */ -import type { ActionEnvelope } from '../../types/common/actions.js'; +import { ActionType, type ActionEnvelope } from '../../types/common/actions.js'; import type { Snapshot, URI } from '../../types/common/state.js'; import type { ChangesetAction, @@ -39,18 +39,21 @@ import type { TerminalAction, } from '../../types/action-origin.generated.js'; import type { ChangesetState } from '../../types/channels-changeset/state.js'; +import type { CanvasState } from '../../types/channels-canvas/state.js'; import type { RootState } from '../../types/channels-root/state.js'; import type { SessionState } from '../../types/channels-session/state.js'; import type { TerminalState } from '../../types/channels-terminal/state.js'; import type { AutomationEntry, AutomationState } from '../../types/channels-automation/state.js'; import type { AutomationRunState } from '../../types/channels-automation-run/state.js'; import { changesetReducer } from '../../types/channels-changeset/reducer.js'; +import { canvasReducer } from '../../types/channels-canvas/reducer.js'; import { rootReducer } from '../../types/channels-root/reducer.js'; import { sessionReducer } from '../../types/channels-session/reducer.js'; import { terminalReducer } from '../../types/channels-terminal/reducer.js'; import { automationReducer } from '../../types/channels-automation/reducer.js'; import { automationRunReducer } from '../../types/channels-automation-run/reducer.js'; import { ROOT_RESOURCE_URI, type HostId, type HostSubscriptionEvent } from './types.js'; +import { isCanvasState } from '../canvas-state.js'; const INITIAL_ROOT: RootState = { agents: [] }; const AUTOMATIONS_URI = 'ahp-automations://' as const; @@ -103,6 +106,7 @@ function hostedResourceKeyPrefix(hostId: HostId): string { export class MultiHostStateMirror { private readonly rootStatesMap = new Map(); private readonly sessionsMap = new Map(); + private readonly canvasesMap = new Map(); private readonly terminalsMap = new Map(); private readonly changesetsMap = new Map(); private readonly automationCatalogsMap = new Map(); @@ -119,6 +123,11 @@ export class MultiHostStateMirror { return this.sessionsMap; } + /** Live canvases keyed by `hostedResourceKey(hostId, uri)`. */ + get canvases(): ReadonlyMap { + return this.canvasesMap; + } + /** All known terminal states keyed by `hostedResourceKey(hostId, uri)`. */ get terminals(): ReadonlyMap { return this.terminalsMap; @@ -154,6 +163,10 @@ export class MultiHostStateMirror { return this.sessionsMap.get(hostedResourceKey(hostId, uri)); } + getCanvas(hostId: HostId, uri: URI): CanvasState | undefined { + return this.canvasesMap.get(hostedResourceKey(hostId, uri)); + } + /** Look up a terminal by `(hostId, uri)`. */ getTerminal(hostId: HostId, uri: URI): TerminalState | undefined { return this.terminalsMap.get(hostedResourceKey(hostId, uri)); @@ -201,6 +214,13 @@ export class MultiHostStateMirror { this.sessionsMap.set(key, sessionReducer(current, action as SessionAction)); return; } + if (channel.startsWith('ahp-canvas:')) { + const key = hostedResourceKey(hostId, channel); + const current = this.canvasesMap.get(key); + if (current === undefined || action.type !== ActionType.CanvasStateChanged) return; + this.canvasesMap.set(key, canvasReducer(current, action)); + return; + } if (channel.startsWith('ahp-terminal:')) { const key = hostedResourceKey(hostId, channel); const current = this.terminalsMap.get(key); @@ -246,6 +266,13 @@ export class MultiHostStateMirror { this.sessionsMap.set(key, snapshot.state as SessionState); return; } + if (resource.startsWith('ahp-canvas:')) { + if (!isCanvasState(snapshot.state)) { + throw new Error('Invalid canvas snapshot state'); + } + this.canvasesMap.set(key, snapshot.state); + return; + } if (resource.startsWith('ahp-terminal:')) { this.terminalsMap.set(key, snapshot.state as TerminalState); return; @@ -272,6 +299,9 @@ export class MultiHostStateMirror { for (const key of this.sessionsMap.keys()) { if (key.startsWith(prefix)) this.sessionsMap.delete(key); } + for (const key of this.canvasesMap.keys()) { + if (key.startsWith(prefix)) this.canvasesMap.delete(key); + } for (const key of this.terminalsMap.keys()) { if (key.startsWith(prefix)) this.terminalsMap.delete(key); } @@ -290,6 +320,7 @@ export class MultiHostStateMirror { reset(): void { this.rootStatesMap.clear(); this.sessionsMap.clear(); + this.canvasesMap.clear(); this.terminalsMap.clear(); this.changesetsMap.clear(); this.automationCatalogsMap.clear(); diff --git a/clients/typescript/src/client/state-mirror.ts b/clients/typescript/src/client/state-mirror.ts index 59d07a34d..5fcb6e118 100644 --- a/clients/typescript/src/client/state-mirror.ts +++ b/clients/typescript/src/client/state-mirror.ts @@ -2,7 +2,7 @@ * Convenience reducer-driven state store, mirroring the Swift * `AHPStateMirror` and the Rust reducers example. * - * Tracks root, session, terminal, changeset, automation catalogue, and + * Tracks root, session, canvas, terminal, changeset, automation catalogue, and * automation-run state. Apply {@link Snapshot}s and {@link ActionEnvelope}s and * the mirror keeps those resources up to date via the generated reducers. * @@ -12,7 +12,7 @@ * @module client/state-mirror */ -import type { ActionEnvelope } from '../types/common/actions.js'; +import { ActionType, type ActionEnvelope } from '../types/common/actions.js'; import type { Snapshot, URI } from '../types/common/state.js'; import type { ChangesetAction, @@ -23,17 +23,20 @@ import type { TerminalAction, } from '../types/action-origin.generated.js'; import type { ChangesetState } from '../types/channels-changeset/state.js'; +import type { CanvasState } from '../types/channels-canvas/state.js'; import type { RootState } from '../types/channels-root/state.js'; import type { SessionState } from '../types/channels-session/state.js'; import type { TerminalState } from '../types/channels-terminal/state.js'; import type { AutomationEntry, AutomationState } from '../types/channels-automation/state.js'; import type { AutomationRunState } from '../types/channels-automation-run/state.js'; import { changesetReducer } from '../types/channels-changeset/reducer.js'; +import { canvasReducer } from '../types/channels-canvas/reducer.js'; import { rootReducer } from '../types/channels-root/reducer.js'; import { sessionReducer } from '../types/channels-session/reducer.js'; import { terminalReducer } from '../types/channels-terminal/reducer.js'; import { automationReducer } from '../types/channels-automation/reducer.js'; import { automationRunReducer } from '../types/channels-automation-run/reducer.js'; +import { isCanvasState } from './canvas-state.js'; const ROOT_URI = 'ahp-root://' as const; const AUTOMATIONS_URI = 'ahp-automations://' as const; @@ -45,6 +48,7 @@ const INITIAL_AUTOMATION_CATALOG: AutomationState = { entries: [] }; export class AhpStateMirror { private rootState: RootState = INITIAL_ROOT; private readonly sessionsMap = new Map(); + private readonly canvasesMap = new Map(); private readonly terminalsMap = new Map(); private readonly changesetsMap = new Map(); private automationCatalogState: AutomationState = INITIAL_AUTOMATION_CATALOG; @@ -61,6 +65,11 @@ export class AhpStateMirror { return this.sessionsMap; } + /** All known live canvases keyed by their channel URI. */ + get canvases(): ReadonlyMap { + return this.canvasesMap; + } + /** All known terminals keyed by URI. */ get terminals(): ReadonlyMap { return this.terminalsMap; @@ -91,6 +100,10 @@ export class AhpStateMirror { return this.sessionsMap.get(uri); } + getCanvas(uri: URI): CanvasState | undefined { + return this.canvasesMap.get(uri); + } + /** Look up a terminal by URI. */ getTerminal(uri: URI): TerminalState | undefined { return this.terminalsMap.get(uri); @@ -115,6 +128,13 @@ export class AhpStateMirror { this.sessionsMap.set(resource, snapshot.state as SessionState); return; } + if (resource.startsWith('ahp-canvas:')) { + if (!isCanvasState(snapshot.state)) { + throw new Error('Invalid canvas snapshot state'); + } + this.canvasesMap.set(resource, snapshot.state); + return; + } if (resource.startsWith('ahp-terminal:')) { this.terminalsMap.set(resource, snapshot.state as TerminalState); return; @@ -155,6 +175,12 @@ export class AhpStateMirror { this.sessionsMap.set(channel, sessionReducer(current, action as SessionAction)); return; } + if (channel.startsWith('ahp-canvas:')) { + const current = this.canvasesMap.get(channel); + if (current === undefined || action.type !== ActionType.CanvasStateChanged) return; + this.canvasesMap.set(channel, canvasReducer(current, action)); + return; + } if (channel.startsWith('ahp-terminal:')) { const current = this.terminalsMap.get(channel); if (!current) return; diff --git a/clients/typescript/test/client.test.ts b/clients/typescript/test/client.test.ts index 6177c092f..9af56e333 100644 --- a/clients/typescript/test/client.test.ts +++ b/clients/typescript/test/client.test.ts @@ -42,6 +42,8 @@ import type { import { JsonRpcErrorCodes } from '../src/types/common/errors.js'; import { AutomationOperation, type AutomationEntry } from '../src/types/channels-automation/state.js'; import { MessageKind } from '../src/types/channels-chat/state.js'; +import type { CanvasState } from '../src/types/channels-canvas/state.js'; +import type { Snapshot } from '../src/types/common/state.js'; const ROOT = 'ahp-root://' as const; const AUTOMATIONS = 'ahp-automations://' as const; @@ -156,6 +158,57 @@ test('subscribe attaches before sending the request and fans out an action', asy await client.shutdown(); }); +test('state mirror replaces and clears live canvas sources', () => { + const mirror = new AhpStateMirror(); + const resource = 'ahp-canvas:/preview'; + const initial: CanvasState = { + instanceId: 'preview', + extensionId: 'project:preview', + canvasId: 'preview', + url: 'https://example.test/old', + }; + mirror.applySnapshot({ resource, state: initial, fromSeq: 1 }); + const replacement: CanvasState = { ...initial, url: 'https://example.test/new' }; + mirror.apply({ + channel: resource, + serverSeq: 2, + origin: undefined, + action: { type: ActionType.CanvasStateChanged, canvas: replacement }, + }); + const replaced = mirror.getCanvas(resource); + const unavailable: CanvasState = { + instanceId: 'preview', + extensionId: 'project:preview', + canvasId: 'preview', + status: 'Reconnecting', + }; + mirror.apply({ + channel: resource, + serverSeq: 3, + origin: undefined, + action: { type: ActionType.CanvasStateChanged, canvas: unavailable }, + }); + assert.deepEqual({ replaced, unavailable: mirror.canvases.get(resource) }, { + replaced: replacement, + unavailable, + }); +}); + +test('state mirror rejects a non-canvas snapshot on a canvas channel', () => { + const mirror = new AhpStateMirror(); + assert.throws(() => mirror.applySnapshot({ + resource: 'ahp-canvas:/preview', + state: { agents: [] }, + fromSeq: 1, + }), /Invalid canvas snapshot/); +}); + +test('state mirror rejects a canvas snapshot with a malformed source field', () => { + const mirror = new AhpStateMirror(); + const malformed: Snapshot = JSON.parse('{"resource":"ahp-canvas:/preview","state":{"instanceId":"preview","extensionId":"project:preview","canvasId":"preview","url":42},"fromSeq":1}'); + assert.throws(() => mirror.applySnapshot(malformed), /Invalid canvas snapshot/); +}); + test('state mirror applies automation catalogue snapshots and actions', () => { const mirror = new AhpStateMirror(); const automation: AutomationEntry = { diff --git a/clients/typescript/test/hosts.test.ts b/clients/typescript/test/hosts.test.ts index 830564b0c..47f24d8ad 100644 --- a/clients/typescript/test/hosts.test.ts +++ b/clients/typescript/test/hosts.test.ts @@ -56,6 +56,8 @@ import { import { PROTOCOL_VERSION } from '../src/types/version/registry.js'; import { SessionStatus } from '../src/types/channels-session/state.js'; +import type { CanvasState } from '../src/types/channels-canvas/state.js'; +import { ActionType } from '../src/types/common/actions.js'; const ROOT = 'ahp-root://' as const; @@ -344,6 +346,42 @@ test('MultiHostStateMirror applies root snapshots scoped to host', () => { assert.equal(mirror.getRoot('host-b')?.agents[0]?.provider, 'vscode'); }); +test('MultiHostStateMirror isolates canvas updates and clears canvas state on reset', () => { + const mirror = new MultiHostStateMirror(); + const resource = 'ahp-canvas:/preview'; + const initial: CanvasState = { + instanceId: 'preview', + extensionId: 'project:preview', + canvasId: 'preview', + url: 'https://example.test/original', + }; + mirror.applySnapshot('host-a', { resource, state: initial, fromSeq: 1 }); + mirror.applySnapshot('host-b', { resource, state: initial, fromSeq: 1 }); + const unavailable: CanvasState = { + instanceId: 'preview', + extensionId: 'project:preview', + canvasId: 'preview', + }; + mirror.applyEnvelope('host-a', { + channel: resource, + serverSeq: 2, + origin: undefined, + action: { type: ActionType.CanvasStateChanged, canvas: unavailable }, + }); + const updated = mirror.getCanvas('host-a', resource); + mirror.resetHost('host-a'); + const afterHostReset = { + removed: mirror.getCanvas('host-a', resource), + retained: mirror.getCanvas('host-b', resource), + }; + mirror.reset(); + assert.deepEqual({ updated, afterHostReset, remaining: mirror.canvases.size }, { + updated: unavailable, + afterHostReset: { removed: undefined, retained: initial }, + remaining: 0, + }); +}); + test('MultiHostStateMirror.resetHost drops every keyed state for that host', () => { const mirror = new MultiHostStateMirror(); mirror.applySnapshot('host-a', { resource: ROOT, state: { agents: [] }, fromSeq: 0 }); diff --git a/docs/.changes/20260924-chat-canvases.json b/docs/.changes/20260924-chat-canvases.json new file mode 100644 index 000000000..1e1cc8489 --- /dev/null +++ b/docs/.changes/20260924-chat-canvases.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels." +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index eb69724be..750d3d8c3 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -90,6 +90,7 @@ export default withMermaid(defineConfig({ { text: 'Root Channel', link: '/reference/root' }, { text: 'Session Channel', link: '/reference/session' }, { text: 'Chat Channel', link: '/reference/chat' }, + { text: 'Canvas Channel', link: '/reference/canvas' }, { text: 'Terminal Channel', link: '/reference/terminal' }, { text: 'Changeset Channel', link: '/reference/changeset' }, { text: 'Annotations Channel', link: '/reference/annotations' }, diff --git a/docs/specification/subscriptions.md b/docs/specification/subscriptions.md index e5aaa3404..f333d7085 100644 --- a/docs/specification/subscriptions.md +++ b/docs/specification/subscriptions.md @@ -28,6 +28,7 @@ The rest of this page details the URI scheme and the lifecycle of a subscription | `ahp-automations://` | `AutomationState` | Full state for every visible automation. Present when `InitializeResult.automations` is advertised. | | `ahp-session:/` | `SessionState` | Per-session state (metadata plus the `chats` catalog). The session's provider is carried on `SessionSummary.provider`, not in the URI scheme. | | `ahp-chat:/` | `ChatState` | Per-chat conversation state (turns, streaming, tool calls, pending messages, input requests, changeset catalogue). A session starts with a default chat; multi-chat hosts add more via `createChat`. See [Chat Channel](/specification/chat-channel). | +| `ahp-canvas:/` | `CanvasState` | Experimental per-canvas live presentation state. Subscribe to the resource advertised in `ChatState.canvases`; the id is host-defined. See [Canvas Channel](/reference/canvas). | | `ahp-terminal:/` | `TerminalState` | Per-terminal state. Server-defined id. | | `ahp-changeset:/` | `ChangesetState` | Per-changeset state. URI is obtained by expanding a `Changeset.uriTemplate` advertised on a session or chat; the id is server-defined. | | `ahp-otlp:` _(authority/path host-defined)_ | _stateless_ | OpenTelemetry signal channels (logs, traces, metrics). Concrete URIs are advertised on `InitializeResult.telemetry`; clients MUST treat them as opaque. See [Telemetry Channel](/specification/telemetry-channel). | diff --git a/docs/specification/versioning.md b/docs/specification/versioning.md index 7e3a91b21..6f61c91ad 100644 --- a/docs/specification/versioning.md +++ b/docs/specification/versioning.md @@ -33,6 +33,7 @@ Until more granular annotations are introduced, a channel's index applies to its | [Root](/specification/root-channel) | **2 - Stable** | | [Session](/specification/session-channel) | **2 - Stable** | | [Chat](/specification/chat-channel) | **2 - Stable** | +| [Canvas](/reference/canvas) | **1 - Experimental** | | [Terminal](/specification/terminal-channel) | **2 - Stable** | | [Changeset](/reference/changeset) | **1.2 - Release candidate** | | [Annotations](/reference/annotations) | **1.1 - Active development** | diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 56571ab87..0ac854782 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -1437,6 +1437,25 @@ "type" ] }, + "ChatCanvasesChangedAction": { + "type": "object", + "description": "The live canvas channels exposed by this chat changed.\n\nReplaces {@link ChatState.canvases | `state.canvases`} entirely. Set to\n`undefined` to clear the collection.", + "properties": { + "type": { + "const": "chat/canvasesChanged" + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "New canvas channel references, or `undefined` to clear the collection." + } + }, + "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}.", @@ -1744,6 +1763,23 @@ "response" ] }, + "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.", + "properties": { + "type": { + "const": "canvas/stateChanged" + }, + "canvas": { + "$ref": "#/$defs/CanvasState", + "description": "New authoritative canvas state." + } + }, + "required": [ + "type", + "canvas" + ] + }, "TerminalDataAction": { "type": "object", "description": "Terminal output data (pty → client direction).\n\nAppends `data` to the terminal's `content` in the reducer.\n\n`terminal/data` and `terminal/input` are intentionally separate actions\nbecause standard write-ahead reconciliation is not safe for terminal I/O.\nA pty is a stateful, mutable process — optimistically applying input or\npredicting output would produce incorrect state. Instead, `terminal/input`\nis a side-effect-only action (client → server → pty), and `terminal/data`\nis server-authoritative output (pty → server → client).", @@ -2552,6 +2588,9 @@ { "$ref": "#/$defs/ChatChangesetsChangedAction" }, + { + "$ref": "#/$defs/ChatCanvasesChangedAction" + }, { "$ref": "#/$defs/ChatWorkingDirectorySetAction" }, @@ -3007,7 +3046,7 @@ "properties": { "resource": { "$ref": "#/$defs/URI", - "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)" + "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`)" }, "state": { "oneOf": [ @@ -3032,6 +3071,9 @@ { "$ref": "#/$defs/ChatState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AutomationState" }, @@ -5075,6 +5117,13 @@ }, "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." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "Live canvases currently exposed by this chat.\n\nEntries intentionally contain only subscribable channel references.\nClients subscribe to each resource for the experimental presentation\nstate, including its current live source URL." + }, "turns": { "type": "array", "items": { @@ -7196,6 +7245,58 @@ "title" ] }, + "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.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Canvas channel URI. Subscribe to this resource for the full state." + } + }, + "required": [ + "resource" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for one live canvas, returned when a client subscribes to its\n`ahp-canvas:` URI.\n\nThe client already knows the subscribed resource, so the state does not\nredundantly carry its channel URI.", + "properties": { + "instanceId": { + "type": "string", + "description": "Stable caller-supplied instance identifier." + }, + "extensionId": { + "type": "string", + "description": "Owning extension/provider identifier." + }, + "extensionName": { + "type": "string", + "description": "Owning extension display name, when available." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "title": { + "type": "string", + "description": "Provider-supplied title, when available." + }, + "status": { + "type": "string", + "description": "Provider-supplied status text, when available." + }, + "url": { + "$ref": "#/$defs/URI", + "description": "Current absolute HTTP(S) source URL; absent when the live source is unavailable.\nHosts MUST clear this field when the provider becomes unavailable.\n\nSource URLs MUST be redacted from diagnostic logs and MUST NOT be reused\nfrom persisted state after a provider or host restart." + } + }, + "required": [ + "instanceId", + "extensionId", + "canvasId" + ] + }, "TerminalInfo": { "type": "object", "description": "Lightweight terminal metadata exposed on the root state.", @@ -9163,6 +9264,12 @@ { "$ref": "#/$defs/ChatChangesetsChangedAction" }, + { + "$ref": "#/$defs/ChatCanvasesChangedAction" + }, + { + "$ref": "#/$defs/CanvasStateChangedAction" + }, { "$ref": "#/$defs/ChatWorkingDirectorySetAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 3ecfad133..699c4b3c0 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2242,7 +2242,7 @@ "properties": { "resource": { "$ref": "#/$defs/URI", - "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)" + "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`)" }, "state": { "oneOf": [ @@ -2267,6 +2267,9 @@ { "$ref": "#/$defs/ChatState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AutomationState" }, @@ -4310,6 +4313,13 @@ }, "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." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "Live canvases currently exposed by this chat.\n\nEntries intentionally contain only subscribable channel references.\nClients subscribe to each resource for the experimental presentation\nstate, including its current live source URL." + }, "turns": { "type": "array", "items": { @@ -6431,6 +6441,58 @@ "title" ] }, + "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.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Canvas channel URI. Subscribe to this resource for the full state." + } + }, + "required": [ + "resource" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for one live canvas, returned when a client subscribes to its\n`ahp-canvas:` URI.\n\nThe client already knows the subscribed resource, so the state does not\nredundantly carry its channel URI.", + "properties": { + "instanceId": { + "type": "string", + "description": "Stable caller-supplied instance identifier." + }, + "extensionId": { + "type": "string", + "description": "Owning extension/provider identifier." + }, + "extensionName": { + "type": "string", + "description": "Owning extension display name, when available." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "title": { + "type": "string", + "description": "Provider-supplied title, when available." + }, + "status": { + "type": "string", + "description": "Provider-supplied status text, when available." + }, + "url": { + "$ref": "#/$defs/URI", + "description": "Current absolute HTTP(S) source URL; absent when the live source is unavailable.\nHosts MUST clear this field when the provider becomes unavailable.\n\nSource URLs MUST be redacted from diagnostic logs and MUST NOT be reused\nfrom persisted state after a provider or host restart." + } + }, + "required": [ + "instanceId", + "extensionId", + "canvasId" + ] + }, "TerminalInfo": { "type": "object", "description": "Lightweight terminal metadata exposed on the root state.", @@ -9092,6 +9154,25 @@ "type" ] }, + "ChatCanvasesChangedAction": { + "type": "object", + "description": "The live canvas channels exposed by this chat changed.\n\nReplaces {@link ChatState.canvases | `state.canvases`} entirely. Set to\n`undefined` to clear the collection.", + "properties": { + "type": { + "const": "chat/canvasesChanged" + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "New canvas channel references, or `undefined` to clear the collection." + } + }, + "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}.", @@ -9399,6 +9480,23 @@ "response" ] }, + "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.", + "properties": { + "type": { + "const": "canvas/stateChanged" + }, + "canvas": { + "$ref": "#/$defs/CanvasState", + "description": "New authoritative canvas state." + } + }, + "required": [ + "type", + "canvas" + ] + }, "TerminalDataAction": { "type": "object", "description": "Terminal output data (pty → client direction).\n\nAppends `data` to the terminal's `content` in the reducer.\n\n`terminal/data` and `terminal/input` are intentionally separate actions\nbecause standard write-ahead reconciliation is not safe for terminal I/O.\nA pty is a stateful, mutable process — optimistically applying input or\npredicting output would produce incorrect state. Instead, `terminal/input`\nis a side-effect-only action (client → server → pty), and `terminal/data`\nis server-authoritative output (pty → server → client).", @@ -10302,6 +10400,12 @@ { "$ref": "#/$defs/ChatChangesetsChangedAction" }, + { + "$ref": "#/$defs/ChatCanvasesChangedAction" + }, + { + "$ref": "#/$defs/CanvasStateChangedAction" + }, { "$ref": "#/$defs/ChatWorkingDirectorySetAction" }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 536be4dc4..df45acf83 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -505,7 +505,7 @@ "properties": { "resource": { "$ref": "#/$defs/URI", - "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)" + "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`)" }, "state": { "oneOf": [ @@ -530,6 +530,9 @@ { "$ref": "#/$defs/ChatState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AutomationState" }, @@ -2573,6 +2576,13 @@ }, "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." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "Live canvases currently exposed by this chat.\n\nEntries intentionally contain only subscribable channel references.\nClients subscribe to each resource for the experimental presentation\nstate, including its current live source URL." + }, "turns": { "type": "array", "items": { @@ -4694,6 +4704,58 @@ "title" ] }, + "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.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Canvas channel URI. Subscribe to this resource for the full state." + } + }, + "required": [ + "resource" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for one live canvas, returned when a client subscribes to its\n`ahp-canvas:` URI.\n\nThe client already knows the subscribed resource, so the state does not\nredundantly carry its channel URI.", + "properties": { + "instanceId": { + "type": "string", + "description": "Stable caller-supplied instance identifier." + }, + "extensionId": { + "type": "string", + "description": "Owning extension/provider identifier." + }, + "extensionName": { + "type": "string", + "description": "Owning extension display name, when available." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "title": { + "type": "string", + "description": "Provider-supplied title, when available." + }, + "status": { + "type": "string", + "description": "Provider-supplied status text, when available." + }, + "url": { + "$ref": "#/$defs/URI", + "description": "Current absolute HTTP(S) source URL; absent when the live source is unavailable.\nHosts MUST clear this field when the provider becomes unavailable.\n\nSource URLs MUST be redacted from diagnostic logs and MUST NOT be reused\nfrom persisted state after a provider or host restart." + } + }, + "required": [ + "instanceId", + "extensionId", + "canvasId" + ] + }, "TerminalInfo": { "type": "object", "description": "Lightweight terminal metadata exposed on the root state.", @@ -8593,6 +8655,12 @@ { "$ref": "#/$defs/ChatChangesetsChangedAction" }, + { + "$ref": "#/$defs/ChatCanvasesChangedAction" + }, + { + "$ref": "#/$defs/CanvasStateChangedAction" + }, { "$ref": "#/$defs/ChatWorkingDirectorySetAction" }, @@ -10142,6 +10210,42 @@ "type" ] }, + "ChatCanvasesChangedAction": { + "type": "object", + "description": "The live canvas channels exposed by this chat changed.\n\nReplaces {@link ChatState.canvases | `state.canvases`} entirely. Set to\n`undefined` to clear the collection.", + "properties": { + "type": { + "const": "chat/canvasesChanged" + }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "New canvas channel references, or `undefined` to clear the collection." + } + }, + "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.", + "properties": { + "type": { + "const": "canvas/stateChanged" + }, + "canvas": { + "$ref": "#/$defs/CanvasState", + "description": "New authoritative canvas state." + } + }, + "required": [ + "type", + "canvas" + ] + }, "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}.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index c43b4d93d..18b180b6f 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -683,7 +683,7 @@ "properties": { "resource": { "$ref": "#/$defs/URI", - "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)" + "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`)" }, "state": { "oneOf": [ @@ -708,6 +708,9 @@ { "$ref": "#/$defs/ChatState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AutomationState" }, @@ -2751,6 +2754,13 @@ }, "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." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "Live canvases currently exposed by this chat.\n\nEntries intentionally contain only subscribable channel references.\nClients subscribe to each resource for the experimental presentation\nstate, including its current live source URL." + }, "turns": { "type": "array", "items": { @@ -4872,6 +4882,58 @@ "title" ] }, + "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.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Canvas channel URI. Subscribe to this resource for the full state." + } + }, + "required": [ + "resource" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for one live canvas, returned when a client subscribes to its\n`ahp-canvas:` URI.\n\nThe client already knows the subscribed resource, so the state does not\nredundantly carry its channel URI.", + "properties": { + "instanceId": { + "type": "string", + "description": "Stable caller-supplied instance identifier." + }, + "extensionId": { + "type": "string", + "description": "Owning extension/provider identifier." + }, + "extensionName": { + "type": "string", + "description": "Owning extension display name, when available." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "title": { + "type": "string", + "description": "Provider-supplied title, when available." + }, + "status": { + "type": "string", + "description": "Provider-supplied status text, when available." + }, + "url": { + "$ref": "#/$defs/URI", + "description": "Current absolute HTTP(S) source URL; absent when the live source is unavailable.\nHosts MUST clear this field when the provider becomes unavailable.\n\nSource URLs MUST be redacted from diagnostic logs and MUST NOT be reused\nfrom persisted state after a provider or host restart." + } + }, + "required": [ + "instanceId", + "extensionId", + "canvasId" + ] + }, "TerminalInfo": { "type": "object", "description": "Lightweight terminal metadata exposed on the root state.", diff --git a/schema/state.schema.json b/schema/state.schema.json index 335969bc1..3f687f7f5 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -416,7 +416,7 @@ "properties": { "resource": { "$ref": "#/$defs/URI", - "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)" + "description": "The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`)" }, "state": { "oneOf": [ @@ -441,6 +441,9 @@ { "$ref": "#/$defs/ChatState" }, + { + "$ref": "#/$defs/CanvasState" + }, { "$ref": "#/$defs/AutomationState" }, @@ -2484,6 +2487,13 @@ }, "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." }, + "canvases": { + "type": "array", + "items": { + "$ref": "#/$defs/CanvasReference" + }, + "description": "Live canvases currently exposed by this chat.\n\nEntries intentionally contain only subscribable channel references.\nClients subscribe to each resource for the experimental presentation\nstate, including its current live source URL." + }, "turns": { "type": "array", "items": { @@ -4605,6 +4615,58 @@ "title" ] }, + "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.", + "properties": { + "resource": { + "$ref": "#/$defs/URI", + "description": "Canvas channel URI. Subscribe to this resource for the full state." + } + }, + "required": [ + "resource" + ] + }, + "CanvasState": { + "type": "object", + "description": "Full state for one live canvas, returned when a client subscribes to its\n`ahp-canvas:` URI.\n\nThe client already knows the subscribed resource, so the state does not\nredundantly carry its channel URI.", + "properties": { + "instanceId": { + "type": "string", + "description": "Stable caller-supplied instance identifier." + }, + "extensionId": { + "type": "string", + "description": "Owning extension/provider identifier." + }, + "extensionName": { + "type": "string", + "description": "Owning extension display name, when available." + }, + "canvasId": { + "type": "string", + "description": "Provider-local canvas type identifier." + }, + "title": { + "type": "string", + "description": "Provider-supplied title, when available." + }, + "status": { + "type": "string", + "description": "Provider-supplied status text, when available." + }, + "url": { + "$ref": "#/$defs/URI", + "description": "Current absolute HTTP(S) source URL; absent when the live source is unavailable.\nHosts MUST clear this field when the provider becomes unavailable.\n\nSource URLs MUST be redacted from diagnostic logs and MUST NOT be reused\nfrom persisted state after a provider or host restart." + } + }, + "required": [ + "instanceId", + "extensionId", + "canvasId" + ] + }, "TerminalInfo": { "type": "object", "description": "Lightweight terminal metadata exposed on the root state.", diff --git a/scripts/find-protocol-sources.ts b/scripts/find-protocol-sources.ts index 79fd2bdc1..0b354db2c 100644 --- a/scripts/find-protocol-sources.ts +++ b/scripts/find-protocol-sources.ts @@ -19,6 +19,7 @@ export const PROTOCOL_SOURCE_DIRS: readonly string[] = [ 'channels-root', 'channels-session', 'channels-chat', + 'channels-canvas', 'channels-terminal', 'channels-changeset', 'channels-annotations', diff --git a/scripts/generate-action-origin.ts b/scripts/generate-action-origin.ts index b38eb2bb7..f8a3bed4b 100644 --- a/scripts/generate-action-origin.ts +++ b/scripts/generate-action-origin.ts @@ -17,7 +17,7 @@ const GENERATED_HEADER = `// Generated from types/actions.ts — do not edit // Run \`npm run generate\` to regenerate. `; -type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; +type ActionScope = 'root' | 'session' | 'chat' | 'canvas' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; interface ActionInfo { /** The interface name (e.g. 'RootAgentsChangedAction') */ @@ -151,6 +151,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const category = getJsDocTag(node as any, 'category') || ''; const scope: ActionScope = category === 'Root Actions' ? 'root' : category === 'Chat Actions' ? 'chat' + : category === 'Canvas Actions' ? 'canvas' : category === 'Terminal Actions' ? 'terminal' : category === 'Changeset Actions' ? 'changeset' : category === 'Annotations Actions' ? 'annotations' @@ -205,6 +206,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const rootActions = actions.filter(a => a.scope === 'root'); const sessionActions = actions.filter(a => a.scope === 'session'); const chatActions = actions.filter(a => a.scope === 'chat'); + const canvasActions = actions.filter(a => a.scope === 'canvas'); const terminalActions = actions.filter(a => a.scope === 'terminal'); const changesetActions = actions.filter(a => a.scope === 'changeset'); const annotationsActions = actions.filter(a => a.scope === 'annotations'); @@ -217,6 +219,8 @@ export function generateActionOrigin(project: Project, outDir: string): void { const serverSessionActions = sessionActions.filter(a => !a.isClientDispatchable); const clientChatActions = chatActions.filter(a => a.isClientDispatchable); const serverChatActions = chatActions.filter(a => !a.isClientDispatchable); + const clientCanvasActions = canvasActions.filter(a => a.isClientDispatchable); + const serverCanvasActions = canvasActions.filter(a => !a.isClientDispatchable); const clientTerminalActions = terminalActions.filter(a => a.isClientDispatchable); const serverTerminalActions = terminalActions.filter(a => !a.isClientDispatchable); const clientChangesetActions = changesetActions.filter(a => a.isClientDispatchable); @@ -244,7 +248,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { lines.push(``); // RootAction - lines.push(`// ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ─────────────────`); + lines.push(`// ─── Per-channel Action Unions ───────────────────────────────────────────────`); lines.push(``); lines.push(`/** Union of all root-scoped actions. */`); lines.push(`export type RootAction =`); @@ -326,6 +330,39 @@ export function generateActionOrigin(project: Project, outDir: string): void { lines.push(`;`); lines.push(``); + // CanvasAction + lines.push(`/** Union of all canvas-scoped actions. */`); + lines.push(`export type CanvasAction =`); + for (const a of canvasActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of canvas actions that clients may dispatch. */`); + lines.push(`export type ClientCanvasAction =`); + if (clientCanvasActions.length === 0) { + lines.push(` never`); + } else { + for (const a of clientCanvasActions) { + lines.push(` | ${a.name}`); + } + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of canvas actions that only the server may produce. */`); + lines.push(`export type ServerCanvasAction =`); + if (serverCanvasActions.length === 0) { + lines.push(` never`); + } else { + for (const a of serverCanvasActions) { + lines.push(` | ${a.name}`); + } + } + lines.push(`;`); + lines.push(``); + // TerminalAction lines.push(`/** Union of all terminal-scoped actions. */`); lines.push(`export type TerminalAction =`); diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index e28c83086..147682ae7 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -789,6 +789,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'BackgroundShellWork' }, { name: 'BackgroundSubagentWork' }, { name: 'ChatState', mutable: true }, + { name: 'CanvasReference' }, + { name: 'CanvasState', mutable: true }, { name: 'ChatInputOption' }, { name: 'ChatInputTextQuestion' }, { name: 'ChatInputNumberQuestion' }, @@ -1419,10 +1421,10 @@ internal sealed class CustomizationEnablementConverter : UnionConverter /// SnapshotState is the state payload of a snapshot — root, session, - /// chat, terminal, changeset, resource-watch, annotations, automation catalogue, + /// chat, canvas, terminal, changeset, resource-watch, annotations, automation catalogue, /// or automation-run state. Read /// probes for distinctive fields in an order where no probe shadows another -/// (chat → session → terminal → changeset → resource-watch → annotations → root). +/// (chat → session → canvas → terminal → changeset → resource-watch → annotations → root). /// [JsonConverter(typeof(SnapshotStateConverter))] public sealed class SnapshotState @@ -1436,6 +1438,9 @@ public sealed class SnapshotState /// Chat state variant, when populated. public ChatState? Chat { get; set; } + /// Canvas state variant, when populated. + public CanvasState? Canvas { get; set; } + /// Terminal state variant, when populated. public TerminalState? Terminal { get; set; } @@ -1484,6 +1489,12 @@ internal sealed class SnapshotStateConverter : JsonConverter // session state was flattened.) result.Session = root.Deserialize(AhpJsonTypeInfo.Get(options)); } + else if (root.TryGetProperty("instanceId", out _) && + root.TryGetProperty("extensionId", out _) && + root.TryGetProperty("canvasId", out _)) + { + result.Canvas = root.Deserialize(AhpJsonTypeInfo.Get(options)); + } else if (root.TryGetProperty("content", out _)) { result.Terminal = root.Deserialize(AhpJsonTypeInfo.Get(options)); @@ -1512,6 +1523,7 @@ internal sealed class SnapshotStateConverter : JsonConverter if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } + if (value.Canvas is not null) { JsonSerializer.Serialize(writer, value.Canvas, AhpJsonTypeInfo.Get(options)); return; } if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, AhpJsonTypeInfo.Get(options)); return; } if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, AhpJsonTypeInfo.Get(options)); return; } if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, AhpJsonTypeInfo.Get(options)); return; } @@ -1659,6 +1671,8 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string { type: 'chat/backgroundWorkRemoved', variantName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, + { type: 'chat/canvasesChanged', variantName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'canvas/stateChanged', variantName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'chat/workingDirectorySet', variantName: 'ChatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, { type: 'chat/workingDirectoryRemoved', variantName: 'ChatWorkingDirectoryRemoved', tsInterface: 'ChatWorkingDirectoryRemovedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 6a0e78ce8..d42b36f71 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -174,6 +174,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | CanvasState | AutomationState | AutomationRunState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState' ) { @@ -759,6 +760,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'SessionChatSummary' }, { name: 'ChangesSummary' }, { name: 'ChatState' }, + { name: 'CanvasReference' }, + { name: 'CanvasState' }, { name: 'ChatSummary' }, { name: 'BackgroundShellWork' }, { name: 'BackgroundSubagentWork' }, @@ -1300,16 +1303,17 @@ func (o ChatOrigin) MarshalJSON() ([]byte, error) { function generateSnapshotState(): string { return `// SnapshotState is the state payload of a snapshot — root, session, -// chat, terminal, changeset, resource-watch, annotations, automation catalogue, +// chat, canvas, terminal, changeset, resource-watch, annotations, automation catalogue, // or automation-run state. The active // variant is chosen by which pointer field is non-nil; UnmarshalJSON probes // for required fields in the canonical order -// (automationRun → automations → session → chat → terminal → changeset → +// (automationRun → automations → session → chat → canvas → terminal → changeset → // resourceWatch → annotations → root). type SnapshotState struct { \tRoot *RootState \`json:"-"\` \tSession *SessionState \`json:"-"\` \tChat *ChatState \`json:"-"\` +\tCanvas *CanvasState \`json:"-"\` \tTerminal *TerminalState \`json:"-"\` \tChangeset *ChangesetState \`json:"-"\` \tResourceWatch *ResourceWatchState \`json:"-"\` @@ -1329,6 +1333,8 @@ func (s SnapshotState) MarshalJSON() ([]byte, error) { \t\treturn json.Marshal(s.Session) \tcase s.Chat != nil: \t\treturn json.Marshal(s.Chat) +\tcase s.Canvas != nil: +\t\treturn json.Marshal(s.Canvas) \tcase s.Terminal != nil: \t\treturn json.Marshal(s.Terminal) \tcase s.Changeset != nil: @@ -1377,6 +1383,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { \t\t\treturn err \t\t} \t\ts.Chat = &v +\tcase containsAll(probe, "instanceId", "extensionId", "canvasId"): +\t\tvar v CanvasState +\t\tif err := json.Unmarshal(data, &v); err != nil { +\t\t\treturn err +\t\t} +\t\ts.Canvas = &v \tcase containsAll(probe, "content"): \t\tvar v TerminalState \t\tif err := json.Unmarshal(data, &v); err != nil { @@ -1583,6 +1595,8 @@ const ACTION_VARIANTS: { { type: 'chat/backgroundWorkRemoved', variantName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, + { type: 'chat/canvasesChanged', variantName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'canvas/stateChanged', variantName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, { type: 'chat/reasoning', variantName: 'ChatReasoning', tsInterface: 'ChatReasoningAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 4594663cb..e7c80977d 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -154,6 +154,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | CanvasState | AutomationState | AutomationRunState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || @@ -838,6 +839,7 @@ sealed interface SnapshotState { @JvmInline value class Root(val value: RootState) : SnapshotState @JvmInline value class Session(val value: SessionState) : SnapshotState @JvmInline value class Chat(val value: ChatState) : SnapshotState + @JvmInline value class Canvas(val value: CanvasState) : SnapshotState @JvmInline value class Terminal(val value: TerminalState) : SnapshotState @JvmInline value class Changeset(val value: ChangesetState) : SnapshotState @JvmInline value class ResourceWatch(val value: ResourceWatchState) : SnapshotState @@ -872,6 +874,8 @@ internal object SnapshotStateSerializer : KSerializer { SnapshotState.Automations(input.json.decodeFromJsonElement(AutomationState.serializer(), element)) obj.containsKey("lifecycle") -> SnapshotState.Session(input.json.decodeFromJsonElement(SessionState.serializer(), element)) obj.containsKey("turns") -> SnapshotState.Chat(input.json.decodeFromJsonElement(ChatState.serializer(), element)) + obj.containsKey("instanceId") && obj.containsKey("extensionId") && obj.containsKey("canvasId") -> + SnapshotState.Canvas(input.json.decodeFromJsonElement(CanvasState.serializer(), element)) obj.containsKey("status") && obj.containsKey("files") -> SnapshotState.Changeset(input.json.decodeFromJsonElement(ChangesetState.serializer(), element)) obj.containsKey("root") && obj.containsKey("recursive") -> @@ -891,6 +895,7 @@ internal object SnapshotStateSerializer : KSerializer { is SnapshotState.Root -> output.json.encodeToJsonElement(RootState.serializer(), value.value) is SnapshotState.Session -> output.json.encodeToJsonElement(SessionState.serializer(), value.value) is SnapshotState.Chat -> output.json.encodeToJsonElement(ChatState.serializer(), value.value) + is SnapshotState.Canvas -> output.json.encodeToJsonElement(CanvasState.serializer(), value.value) is SnapshotState.Terminal -> output.json.encodeToJsonElement(TerminalState.serializer(), value.value) is SnapshotState.Changeset -> output.json.encodeToJsonElement(ChangesetState.serializer(), value.value) is SnapshotState.ResourceWatch -> output.json.encodeToJsonElement(ResourceWatchState.serializer(), value.value) @@ -992,6 +997,7 @@ const STATE_STRUCTS = [ 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SideChatSelection', 'SessionState', 'SessionActiveClient', 'BackgroundShellWork', 'BackgroundSubagentWork', + 'CanvasReference', 'CanvasState', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', 'SessionToolAuthenticationRequest', 'SessionSummary', 'SessionChatSummary', 'ChangesSummary', 'ProjectInfo', 'SessionConfigState', 'Turn', 'ActiveTurn', 'Message', @@ -1523,6 +1529,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/backgroundWorkRemoved', caseName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', caseName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', caseName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, + { type: 'chat/canvasesChanged', caseName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'canvas/stateChanged', caseName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', caseName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, { type: 'chat/reasoning', caseName: 'ChatReasoning', tsInterface: 'ChatReasoningAction' }, diff --git a/scripts/generate-markdown.ts b/scripts/generate-markdown.ts index 334349962..e2c0b9497 100644 --- a/scripts/generate-markdown.ts +++ b/scripts/generate-markdown.ts @@ -67,6 +67,7 @@ const DIR_TO_PAGE: Record = { 'channels-root': 'root', 'channels-session': 'session', 'channels-chat': 'chat', + 'channels-canvas': 'canvas', 'channels-terminal': 'terminal', 'channels-changeset': 'changeset', 'channels-annotations': 'annotations', @@ -997,6 +998,30 @@ function generateChatChannelPage(project: Project): string { return lines.join('\n'); } +function generateCanvasChannelPage(project: Project): string { + currentPage = 'canvas'; + const stateSf = findChannelSourceFile(project, 'channels-canvas', 'state.ts'); + const actionsSf = findChannelSourceFile(project, 'channels-canvas', 'actions.ts'); + + const lines: string[] = [GENERATED_HEADER]; + lines.push('# Canvas Channel\n'); + lines.push(stabilityIndex(stateSf)); + lines.push('Reference for the experimental `ahp-canvas:` channel. Chat state advertises minimal canvas resource references; clients subscribe to each resource for its live presentation metadata and HTTP(S) source URL. A missing URL means the source is unavailable. Source URLs must be redacted from diagnostic logs and must not be reused from persisted state after provider or host restart.\n'); + lines.push(schemaLink('state.schema.json')); + + if (stateSf) { + lines.push('## State Types\n'); + lines.push(emitStateTypesSection([stateSf])); + } + if (actionsSf) { + lines.push('## Actions\n'); + lines.push('Replace `CanvasState` through server-authoritative actions scoped to the canvas URI via `ActionEnvelope.channel`.\n'); + lines.push(schemaLink('actions.schema.json')); + lines.push(emitActionsSection([actionsSf])); + } + return lines.join('\n'); +} + function generateTerminalChannelPage(project: Project): string { currentPage = 'terminal'; const stateSf = findChannelSourceFile(project, 'channels-terminal', 'state.ts'); @@ -1413,6 +1438,7 @@ export function generateMarkdownDocs(project: Project, outDir: string): void { { filename: 'root.md', generator: generateRootChannelPage }, { filename: 'session.md', generator: generateSessionChannelPage }, { filename: 'chat.md', generator: generateChatChannelPage }, + { filename: 'canvas.md', generator: generateCanvasChannelPage }, { filename: 'terminal.md', generator: generateTerminalChannelPage }, { filename: 'changeset.md', generator: generateChangesetChannelPage }, { filename: 'annotations.md', generator: generateAnnotationsChannelPage }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 22eda3358..4ad0b632c 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -164,6 +164,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | CanvasState | AutomationState | AutomationRunState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -863,6 +864,8 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'ConfigSchema' }, { name: 'PendingMessage' }, { name: 'ChatState' }, + { name: 'CanvasReference' }, + { name: 'CanvasState' }, { name: 'ChatSummary' }, { name: 'BackgroundShellWork', omitDiscriminants: true }, { name: 'BackgroundSubagentWork', omitDiscriminants: true }, @@ -1366,7 +1369,8 @@ function generateSnapshotState(): string { return `/// The state payload of a snapshot. /// /// Deserialized by trying session first (has required \`lifecycle\`), then -/// chat (has required \`turns\`), then terminal (has required \`content\`), +/// chat (has required \`turns\`), then canvas (has required instance/provider/type +/// identifiers), then terminal (has required \`content\`), /// then changeset (has required \`status\` and \`files\`), then resource-watch /// (has required \`root\` and \`recursive\`), then annotations (has required /// \`annotations\`), then the automation catalogue (has required @@ -1376,6 +1380,7 @@ function generateSnapshotState(): string { pub enum SnapshotState { Session(Box), Chat(Box), + Canvas(Box), Terminal(Box), Changeset(Box), ResourceWatch(Box), @@ -1540,6 +1545,8 @@ const ACTION_VARIANTS: { { type: 'chat/backgroundWorkRemoved', variantName: 'ChatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', variantName: 'ChatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', variantName: 'ChatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, + { type: 'chat/canvasesChanged', variantName: 'ChatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'canvas/stateChanged', variantName: 'CanvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', variantName: 'SessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', variantName: 'ChatUsage', tsInterface: 'ChatUsageAction' }, { type: 'chat/reasoning', variantName: 'ChatReasoning', tsInterface: 'ChatReasoningAction' }, @@ -1689,7 +1696,8 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, BackgroundWork, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, BackgroundWork, CanvasReference, CanvasState, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push(''); // ActionType enum lines.push('// ─── ActionType ──────────────────────────────────────────────────────\n'); const actionTypeEnum = findEnum(project, 'ActionType'); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 584d16bb3..9aff46aac 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -120,6 +120,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | CanvasState | AutomationState | AutomationRunState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -742,6 +743,7 @@ const STATE_STRUCTS = [ 'TerminalRunningLifecycleState', 'TerminalExitedLifecycleState', 'TerminalState', 'TerminalUnclassifiedPart', 'TerminalCommandPart', 'UsageInfo', 'ErrorInfo', 'Snapshot', + 'CanvasReference', 'CanvasState', 'Changeset', 'ChangesetCapabilities', 'ChangesetState', 'ChangesetFile', 'ChangesetOperation', 'AnnotationsSummary', 'AnnotationsState', 'AnnotationOrigin', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', @@ -1112,6 +1114,7 @@ public enum SnapshotState: Codable, Sendable { case root(RootState) case session(SessionState) case chat(ChatState) + case canvas(CanvasState) case terminal(TerminalState) case changeset(ChangesetState) case resourceWatch(ResourceWatchState) @@ -1122,12 +1125,15 @@ public enum SnapshotState: Codable, Sendable { public init(from decoder: Decoder) throws { // Try the most distinctive shapes first. SessionState has required // \`lifecycle\` / \`activeClients\` / \`chats\`; ChatState has required - // \`turns\`; the remaining variants follow, with RootState as the + // \`turns\`; CanvasState has required instance/provider/type identifiers; + // the remaining variants follow, with RootState as the // catch-all. if let session = try? SessionState(from: decoder) { self = .session(session) } else if let chat = try? ChatState(from: decoder) { self = .chat(chat) + } else if let canvas = try? CanvasState(from: decoder) { + self = .canvas(canvas) } else if let terminal = try? TerminalState(from: decoder) { self = .terminal(terminal) } else if let changeset = try? ChangesetState(from: decoder) { @@ -1150,6 +1156,7 @@ public enum SnapshotState: Codable, Sendable { case .root(let state): try state.encode(to: encoder) case .session(let state): try state.encode(to: encoder) case .chat(let state): try state.encode(to: encoder) + case .canvas(let state): try state.encode(to: encoder) case .terminal(let state): try state.encode(to: encoder) case .changeset(let state): try state.encode(to: encoder) case .resourceWatch(let state): try state.encode(to: encoder) @@ -1421,6 +1428,8 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'chat/backgroundWorkRemoved', caseName: 'chatBackgroundWorkRemoved', tsInterface: 'ChatBackgroundWorkRemovedAction' }, { type: 'chat/movableChanged', caseName: 'chatMovableChanged', tsInterface: 'ChatMovableChangedAction' }, { type: 'chat/changesetsChanged', caseName: 'chatChangesetsChanged', tsInterface: 'ChatChangesetsChangedAction' }, + { type: 'chat/canvasesChanged', caseName: 'chatCanvasesChanged', tsInterface: 'ChatCanvasesChangedAction' }, + { type: 'canvas/stateChanged', caseName: 'canvasStateChanged', tsInterface: 'CanvasStateChangedAction' }, { type: 'session/titleChanged', caseName: 'sessionTitleChanged', tsInterface: 'SessionTitleChangedAction' }, { type: 'chat/usage', caseName: 'chatUsage', tsInterface: 'ChatUsageAction' }, { type: 'chat/reasoning', caseName: 'chatReasoning', tsInterface: 'ChatReasoningAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index b3a70ac6b..dce2fa67c 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -58,6 +58,8 @@ import type { ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, + ChatCanvasesChangedAction, + CanvasStateChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, @@ -110,7 +112,7 @@ import type { import { ActionType } from './actions.js'; -// ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── +// ─── Per-channel Action Unions ─────────────────────────────────────────────── /** Union of all root-scoped actions. */ export type RootAction = @@ -227,6 +229,7 @@ export type ChatAction = | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction + | ChatCanvasesChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatUsageAction @@ -280,12 +283,28 @@ export type ServerChatAction = | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction + | ChatCanvasesChangedAction | ChatUsageAction | ChatReasoningAction | ChatInputRequestedAction | ChatTurnsLoadedAction ; +/** Union of all canvas-scoped actions. */ +export type CanvasAction = + | CanvasStateChangedAction +; + +/** Union of canvas actions that clients may dispatch. */ +export type ClientCanvasAction = + never +; + +/** Union of canvas actions that only the server may produce. */ +export type ServerCanvasAction = + | CanvasStateChangedAction +; + /** Union of all terminal-scoped actions. */ export type TerminalAction = | TerminalDataAction @@ -490,6 +509,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatBackgroundWorkRemoved]: false, [ActionType.ChatMovableChanged]: false, [ActionType.ChatChangesetsChanged]: false, + [ActionType.ChatCanvasesChanged]: false, + [ActionType.CanvasStateChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, [ActionType.ChatWorkingDirectoryRemoved]: true, [ActionType.ChatUsage]: false, diff --git a/types/actions.ts b/types/actions.ts index 97ec41dc1..184714927 100644 --- a/types/actions.ts +++ b/types/actions.ts @@ -11,6 +11,7 @@ export * from './common/actions.js'; export * from './channels-root/actions.js'; export * from './channels-session/actions.js'; export * from './channels-chat/actions.js'; +export * from './channels-canvas/actions.js'; export * from './channels-terminal/actions.js'; export * from './channels-changeset/actions.js'; export * from './channels-annotations/actions.js'; diff --git a/types/channels-canvas/actions.ts b/types/channels-canvas/actions.ts new file mode 100644 index 000000000..eae8555f9 --- /dev/null +++ b/types/channels-canvas/actions.ts @@ -0,0 +1,24 @@ +/** + * Canvas Channel Actions — Mutations of an `ahp-canvas:` channel's state. + * + * @module channels-canvas/actions + */ + +import { ActionType } from '../common/actions.js'; +import type { CanvasState } from './state.js'; + +/** + * The presentation state for this canvas changed. + * + * Replaces the subscribed canvas channel state entirely. Full-replacement + * semantics intentionally keep this early-development channel free to evolve + * without expanding the stable chat action surface. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasStateChangedAction { + type: ActionType.CanvasStateChanged; + /** New authoritative canvas state. */ + canvas: CanvasState; +} diff --git a/types/channels-canvas/reducer.ts b/types/channels-canvas/reducer.ts new file mode 100644 index 000000000..644c43d38 --- /dev/null +++ b/types/channels-canvas/reducer.ts @@ -0,0 +1,25 @@ +/** + * Canvas Channel Reducer — Pure reducer for `CanvasState`. + * + * @module channels-canvas/reducer + */ + +import { ActionType } from '../common/actions.js'; +import type { CanvasAction } from '../action-origin.generated.js'; +import type { CanvasState } from './state.js'; + +/** + * Pure reducer for canvas state. + * + * The channel currently has one full-replacement action. The `if` form keeps + * unknown future actions forward-compatible because TypeScript cannot narrow a + * single-variant action union to `never` for the usual exhaustiveness helper. + */ +export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (msg: string) => void): CanvasState { + if (action.type === ActionType.CanvasStateChanged) { + return action.canvas; + } + + (log ?? console.warn)(`Unhandled action type: ${JSON.stringify(action)}`); + return state; +} diff --git a/types/channels-canvas/state.ts b/types/channels-canvas/state.ts new file mode 100644 index 000000000..f80af3387 --- /dev/null +++ b/types/channels-canvas/state.ts @@ -0,0 +1,56 @@ +/** + * Canvas Channel State Types — Per-canvas presentation state exposed on + * `ahp-canvas:` channels. + * + * Stability: 1 - Experimental + * + * @module channels-canvas/state + */ + +import type { URI } from '../common/state.js'; + +/** + * Stable reference to a subscribable canvas channel. + * + * Chat state intentionally carries only this reference so the experimental + * canvas presentation model can evolve without changing the stable chat + * channel shape. + * + * @category Canvas State + */ +export interface CanvasReference { + /** Canvas channel URI. Subscribe to this resource for the full state. */ + resource: URI; +} + +/** + * Full state for one live canvas, returned when a client subscribes to its + * `ahp-canvas:` URI. + * + * The client already knows the subscribed resource, so the state does not + * redundantly carry its channel URI. + * + * @category Canvas State + */ +export interface CanvasState { + /** Stable caller-supplied instance identifier. */ + instanceId: string; + /** Owning extension/provider identifier. */ + extensionId: string; + /** Owning extension display name, when available. */ + extensionName?: string; + /** Provider-local canvas type identifier. */ + canvasId: string; + /** Provider-supplied title, when available. */ + title?: string; + /** Provider-supplied status text, when available. */ + status?: string; + /** + * Current absolute HTTP(S) source URL; absent when the live source is unavailable. + * Hosts MUST clear this field when the provider becomes unavailable. + * + * Source URLs MUST be redacted from diagnostic logs and MUST NOT be reused + * from persisted state after a provider or host restart. + */ + url?: URI; +} diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index 692a1ad3f..c98b1fece 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -6,6 +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 { Changeset } from '../channels-changeset/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import type { @@ -610,6 +611,21 @@ export interface ChatChangesetsChangedAction { changesets: Changeset[] | undefined; } +/** + * The live canvas channels exposed by this chat changed. + * + * Replaces {@link ChatState.canvases | `state.canvases`} entirely. Set to + * `undefined` to clear the collection. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatCanvasesChangedAction { + type: ActionType.ChatCanvasesChanged; + /** New canvas channel references, or `undefined` to clear the collection. */ + canvases: CanvasReference[] | undefined; +} + /** * A working directory was added to this chat's * {@link ChatState.workingDirectories} subset. @@ -934,6 +950,7 @@ export type ChatAction = | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction + | ChatCanvasesChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatUsageAction diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 1cf16f187..eaf653b88 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -482,6 +482,13 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st : stateWithoutChangesets; } + case ActionType.ChatCanvasesChanged: { + const { canvases: _omit, ...stateWithoutCanvases } = state; + return action.canvases + ? { ...stateWithoutCanvases, canvases: action.canvases } + : stateWithoutCanvases; + } + // ── Working Directories ─────────────────────────────────────────────── case ActionType.ChatWorkingDirectorySet: { diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index a3fff2314..0cad6ffe2 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -15,6 +15,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 { ContentRef, ErrorInfo, @@ -117,6 +118,14 @@ export interface ChatState { * chat channel. */ backgroundWork?: BackgroundWork[]; + /** + * Live canvases currently exposed by this chat. + * + * Entries intentionally contain only subscribable channel references. + * Clients subscribe to each resource for the experimental presentation + * state, including its current live source URL. + */ + canvases?: CanvasReference[]; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ diff --git a/types/common/actions.ts b/types/common/actions.ts index 14c462417..efe3e9be7 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -70,6 +70,7 @@ import type { ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, + ChatCanvasesChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, @@ -86,6 +87,10 @@ import type { ChatTurnsLoadedAction, } from '../channels-chat/actions.js'; +import type { + CanvasStateChangedAction, +} from '../channels-canvas/actions.js'; + import type { ChangesetStatusChangedAction, ChangesetFileSetAction, @@ -175,6 +180,8 @@ export const enum ActionType { ChatBackgroundWorkRemoved = 'chat/backgroundWorkRemoved', ChatMovableChanged = 'chat/movableChanged', ChatChangesetsChanged = 'chat/changesetsChanged', + ChatCanvasesChanged = 'chat/canvasesChanged', + CanvasStateChanged = 'canvas/stateChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', SessionTitleChanged = 'session/titleChanged', @@ -339,6 +346,8 @@ export type StateAction = | ChatBackgroundWorkRemovedAction | ChatMovableChangedAction | ChatChangesetsChangedAction + | ChatCanvasesChangedAction + | CanvasStateChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatUsageAction diff --git a/types/common/state.ts b/types/common/state.ts index b6288a35d..b9a03b4bf 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -14,6 +14,7 @@ import type { ChangesetState } from '../channels-changeset/state.js'; import type { ResourceWatchState } from '../channels-resource-watch/state.js'; import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; +import type { CanvasState } from '../channels-canvas/state.js'; import type { AutomationState } from '../channels-automation/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; @@ -349,10 +350,10 @@ export interface ErrorInfo { * @category Common Types */ export interface Snapshot { - /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ + /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, `ahp-chat:/`, or `ahp-canvas:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | CanvasState | AutomationState | AutomationRunState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/types/index.ts b/types/index.ts index 048e196fe..0f730480e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -25,6 +25,7 @@ export { rootReducer, sessionReducer, chatReducer, + canvasReducer, terminalReducer, changesetReducer, annotationsReducer, diff --git a/types/messages.test.ts b/types/messages.test.ts index 7abe8b024..f2474fe89 100644 --- a/types/messages.test.ts +++ b/types/messages.test.ts @@ -29,6 +29,7 @@ function readChannelSources(baseName: string): string { 'channels-root', 'channels-session', 'channels-chat', + 'channels-canvas', 'channels-terminal', 'channels-changeset', 'channels-annotations', diff --git a/types/reducers.test.ts b/types/reducers.test.ts index cc90200a3..083e39756 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -21,6 +21,7 @@ import { rootReducer, sessionReducer, chatReducer, + canvasReducer, terminalReducer, changesetReducer, annotationsReducer, @@ -31,7 +32,7 @@ import { } from './reducers.js'; import { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; import { ActionType } from './actions.js'; -import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState } from './state.js'; +import type { RootState, SessionState, ChatState, CanvasState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState } from './state.js'; import { SessionStatus, TurnState, @@ -52,6 +53,7 @@ function readChannelSources(baseName: string): string { 'channels-root', 'channels-session', 'channels-chat', + 'channels-canvas', 'channels-terminal', 'channels-changeset', 'channels-annotations', @@ -73,11 +75,11 @@ function readChannelSources(baseName: string): string { // ─── Fixture Loading ───────────────────────────────────────────────────────── -type FixtureState = RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState; +type FixtureState = RootState | SessionState | ChatState | CanvasState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState; interface Fixture { description: string; - reducer: 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; + reducer: 'root' | 'session' | 'chat' | 'canvas' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; initial: FixtureState; actions: unknown[]; expected: FixtureState; @@ -119,6 +121,8 @@ describe('reducer fixtures', () => { state = rootReducer(state as RootState, action as any); } else if (fixture.reducer === 'chat') { state = chatReducer(state as ChatState, action as any); + } else if (fixture.reducer === 'canvas') { + state = canvasReducer(state as CanvasState, action as any); } else if (fixture.reducer === 'terminal') { state = terminalReducer(state as TerminalState, action as any); } else if (fixture.reducer === 'changeset') { diff --git a/types/reducers.ts b/types/reducers.ts index f6cbdb31f..a7a011a5e 100644 --- a/types/reducers.ts +++ b/types/reducers.ts @@ -8,6 +8,7 @@ export { rootReducer } from './channels-root/reducer.js'; export { sessionReducer } from './channels-session/reducer.js'; export { chatReducer } from './channels-chat/reducer.js'; +export { canvasReducer } from './channels-canvas/reducer.js'; export { terminalReducer } from './channels-terminal/reducer.js'; export { changesetReducer } from './channels-changeset/reducer.js'; export { annotationsReducer } from './channels-annotations/reducer.js'; diff --git a/types/state.ts b/types/state.ts index 28b13bfd0..dd6262d98 100644 --- a/types/state.ts +++ b/types/state.ts @@ -11,6 +11,7 @@ export * from './common/state.js'; export * from './channels-root/state.js'; export * from './channels-session/state.js'; export * from './channels-chat/state.js'; +export * from './channels-canvas/state.js'; export * from './channels-terminal/state.js'; export * from './channels-changeset/state.js'; export * from './channels-annotations/state.js'; diff --git a/types/test-cases/reducers/274-chat-canvaseschanged-sets-canvases.json b/types/test-cases/reducers/274-chat-canvaseschanged-sets-canvases.json new file mode 100644 index 000000000..482a9ffa3 --- /dev/null +++ b/types/test-cases/reducers/274-chat-canvaseschanged-sets-canvases.json @@ -0,0 +1,33 @@ +{ + "description": "chat/canvasesChanged replaces state.canvases with the live collection", + "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/canvasesChanged", + "canvases": [ + { + "resource": "ahp-canvas:/test-session/main/preview-1" + } + ] + } + ], + "expected": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "canvases": [ + { + "resource": "ahp-canvas:/test-session/main/preview-1" + } + ] + } +} diff --git a/types/test-cases/reducers/275-chat-canvaseschanged-clears-canvases.json b/types/test-cases/reducers/275-chat-canvaseschanged-clears-canvases.json new file mode 100644 index 000000000..20929c746 --- /dev/null +++ b/types/test-cases/reducers/275-chat-canvaseschanged-clears-canvases.json @@ -0,0 +1,29 @@ +{ + "description": "chat/canvasesChanged clears state.canvases", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "ahp-chat:/test-session/main", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "canvases": [ + { + "resource": "ahp-canvas:/test-session/main/preview-1" + } + ] + }, + "actions": [ + { + "type": "chat/canvasesChanged", + "canvases": null + } + ], + "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/277-canvas-statechanged-replaces-state.json b/types/test-cases/reducers/277-canvas-statechanged-replaces-state.json new file mode 100644 index 000000000..1be65b786 --- /dev/null +++ b/types/test-cases/reducers/277-canvas-statechanged-replaces-state.json @@ -0,0 +1,32 @@ +{ + "description": "canvas/stateChanged supplies the live source when a canvas becomes available", + "reducer": "canvas", + "initial": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "canvasId": "preview" + }, + "actions": [ + { + "type": "canvas/stateChanged", + "canvas": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "extensionName": "Preview", + "canvasId": "preview", + "title": "Preview", + "status": "Ready", + "url": "https://example.test/live" + } + } + ], + "expected": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "extensionName": "Preview", + "canvasId": "preview", + "title": "Preview", + "status": "Ready", + "url": "https://example.test/live" + } +} diff --git a/types/test-cases/reducers/278-canvas-statechanged-clears-source.json b/types/test-cases/reducers/278-canvas-statechanged-clears-source.json new file mode 100644 index 000000000..299cd96bd --- /dev/null +++ b/types/test-cases/reducers/278-canvas-statechanged-clears-source.json @@ -0,0 +1,30 @@ +{ + "description": "canvas/stateChanged clears the previous URL when the source becomes unavailable", + "reducer": "canvas", + "initial": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "canvasId": "preview", + "title": "Preview", + "url": "https://example.test/live" + }, + "actions": [ + { + "type": "canvas/stateChanged", + "canvas": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "canvasId": "preview", + "title": "Preview", + "status": "Reconnecting" + } + } + ], + "expected": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "canvasId": "preview", + "title": "Preview", + "status": "Reconnecting" + } +} diff --git a/types/test-cases/reducers/279-canvas-statechanged-replaces-source.json b/types/test-cases/reducers/279-canvas-statechanged-replaces-source.json new file mode 100644 index 000000000..7d51ece8c --- /dev/null +++ b/types/test-cases/reducers/279-canvas-statechanged-replaces-source.json @@ -0,0 +1,33 @@ +{ + "description": "canvas/stateChanged replaces the URL while preserving logical canvas identity and metadata", + "reducer": "canvas", + "initial": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "canvasId": "preview", + "title": "Preview", + "status": "Ready", + "url": "https://example.test/old" + }, + "actions": [ + { + "type": "canvas/stateChanged", + "canvas": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "canvasId": "preview", + "title": "Preview", + "status": "Ready", + "url": "https://example.test/new" + } + } + ], + "expected": { + "instanceId": "preview-1", + "extensionId": "project:preview", + "canvasId": "preview", + "title": "Preview", + "status": "Ready", + "url": "https://example.test/new" + } +} diff --git a/types/test-cases/round-trips/051-canvas-ready-snapshot.json b/types/test-cases/round-trips/051-canvas-ready-snapshot.json new file mode 100644 index 000000000..e3996ae97 --- /dev/null +++ b/types/test-cases/round-trips/051-canvas-ready-snapshot.json @@ -0,0 +1,32 @@ +{ + "name": "canvas-ready-snapshot", + "group": "A", + "description": "A canvas snapshot preserves its live URL and presentation metadata.", + "type": "Snapshot", + "input": { + "resource": "ahp-canvas:/preview", + "state": { + "instanceId": "preview", + "extensionId": "project:preview", + "extensionName": "Preview", + "canvasId": "preview", + "title": "Preview", + "status": "Ready", + "url": "https://example.test/preview" + }, + "fromSeq": 5 + }, + "acceptableOutputs": [{ + "resource": "ahp-canvas:/preview", + "state": { + "instanceId": "preview", + "extensionId": "project:preview", + "extensionName": "Preview", + "canvasId": "preview", + "title": "Preview", + "status": "Ready", + "url": "https://example.test/preview" + }, + "fromSeq": 5 + }] +} diff --git a/types/test-cases/round-trips/052-canvas-unavailable-snapshot.json b/types/test-cases/round-trips/052-canvas-unavailable-snapshot.json new file mode 100644 index 000000000..b770b3be8 --- /dev/null +++ b/types/test-cases/round-trips/052-canvas-unavailable-snapshot.json @@ -0,0 +1,26 @@ +{ + "name": "canvas-unavailable-snapshot", + "group": "A", + "description": "A canvas snapshot remains a canvas when no live source URL is available.", + "type": "Snapshot", + "input": { + "resource": "ahp-canvas:/preview", + "state": { + "instanceId": "preview", + "extensionId": "project:preview", + "canvasId": "preview", + "status": "Reconnecting" + }, + "fromSeq": 6 + }, + "acceptableOutputs": [{ + "resource": "ahp-canvas:/preview", + "state": { + "instanceId": "preview", + "extensionId": "project:preview", + "canvasId": "preview", + "status": "Reconnecting" + }, + "fromSeq": 6 + }] +} diff --git a/types/test-cases/round-trips/053-canvas-statechanged-action.json b/types/test-cases/round-trips/053-canvas-statechanged-action.json new file mode 100644 index 000000000..1ed4b0dcb --- /dev/null +++ b/types/test-cases/round-trips/053-canvas-statechanged-action.json @@ -0,0 +1,24 @@ +{ + "name": "canvas-statechanged-action", + "group": "A", + "description": "The server-authoritative canvas state replacement action preserves its typed state.", + "type": "StateAction", + "input": { + "type": "canvas/stateChanged", + "canvas": { + "instanceId": "preview", + "extensionId": "project:preview", + "canvasId": "preview", + "url": "https://example.test/replacement" + } + }, + "acceptableOutputs": [{ + "type": "canvas/stateChanged", + "canvas": { + "instanceId": "preview", + "extensionId": "project:preview", + "canvasId": "preview", + "url": "https://example.test/replacement" + } + }] +} diff --git a/types/test-cases/round-trips/054-chat-canvas-references.json b/types/test-cases/round-trips/054-chat-canvas-references.json new file mode 100644 index 000000000..7ffea11d2 --- /dev/null +++ b/types/test-cases/round-trips/054-chat-canvas-references.json @@ -0,0 +1,30 @@ +{ + "name": "chat-canvas-references", + "group": "A", + "description": "Chat snapshots carry only minimal references to independent canvas channels.", + "type": "Snapshot", + "input": { + "resource": "ahp-chat:/main", + "state": { + "resource": "ahp-chat:/main", + "title": "Main", + "status": 1, + "modifiedAt": "2026-10-02T10:00:00.000Z", + "turns": [], + "canvases": [{ "resource": "ahp-canvas:/preview" }] + }, + "fromSeq": 7 + }, + "acceptableOutputs": [{ + "resource": "ahp-chat:/main", + "state": { + "resource": "ahp-chat:/main", + "title": "Main", + "status": 1, + "modifiedAt": "2026-10-02T10:00:00.000Z", + "turns": [], + "canvases": [{ "resource": "ahp-canvas:/preview" }] + }, + "fromSeq": 7 + }] +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 3709de851..f6882cd8f 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.9.0'; +export const PROTOCOL_VERSION = '0.10.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,6 +34,7 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -133,6 +134,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatBackgroundWorkRemoved]: '0.9.0', [ActionType.ChatMovableChanged]: '0.9.0', [ActionType.ChatChangesetsChanged]: '0.9.0', + [ActionType.ChatCanvasesChanged]: '0.10.0', + [ActionType.CanvasStateChanged]: '0.10.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', [ActionType.ChatUsage]: '0.4.0',