Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions clients/dotnet/release-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"client": "dotnet",
"packageVersion": "0.9.0",
"supportedProtocolVersions": [
"0.10.0",
"0.9.0",
"0.8.0",
"0.7.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -1821,6 +1825,32 @@ public sealed record ChatChangesetsChangedAction
public List<Changeset>? Changesets { get; init; }
}

/// <summary>The live canvas channels exposed by this chat changed.
///
/// Replaces {@link ChatState.canvases | `state.canvases`} entirely. Set to
/// `undefined` to clear the collection.</summary>
public sealed record ChatCanvasesChangedAction
{
public ActionType Type { get; init; } = ActionType.ChatCanvasesChanged;

/// <summary>New canvas channel references, or `undefined` to clear the collection.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<CanvasReference>? Canvases { get; init; }
}

/// <summary>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.</summary>
public sealed record CanvasStateChangedAction
{
public ActionType Type { get; init; } = ActionType.CanvasStateChanged;

/// <summary>New authoritative canvas state.</summary>
public required CanvasState Canvas { get; init; }
}

/// <summary>A working directory was added to this chat's
/// {@link ChatState.workingDirectories} subset.
///
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand All @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2420,6 +2420,14 @@ public sealed class ChatState
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<BackgroundWork>? BackgroundWork { get; set; }

/// <summary>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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<CanvasReference>? Canvases { get; set; }

/// <summary>Completed turns</summary>
public required List<Turn> Turns { get; set; }

Expand Down Expand Up @@ -2464,6 +2472,54 @@ public sealed class ChatState
public Dictionary<string, JsonElement>? Meta { get; set; }
}

/// <summary>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.</summary>
public sealed record CanvasReference
{
/// <summary>Canvas channel URI. Subscribe to this resource for the full state.</summary>
public required string Resource { get; init; }
}

/// <summary>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.</summary>
public sealed class CanvasState
{
/// <summary>Stable caller-supplied instance identifier.</summary>
public required string InstanceId { get; set; }

/// <summary>Owning extension/provider identifier.</summary>
public required string ExtensionId { get; set; }

/// <summary>Owning extension display name, when available.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ExtensionName { get; set; }

/// <summary>Provider-local canvas type identifier.</summary>
public required string CanvasId { get; set; }

/// <summary>Provider-supplied title, when available.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Title { get; set; }

/// <summary>Provider-supplied status text, when available.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Status { get; set; }

/// <summary>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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Url { get; set; }
}

/// <summary>A choice in a select-style question.</summary>
public sealed record ChatInputOption
{
Expand Down Expand Up @@ -5866,7 +5922,7 @@ public sealed record ErrorInfo
/// `initialize`, `reconnect`, and `subscribe`.</summary>
public sealed record Snapshot
{
/// <summary>The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/&lt;uuid&gt;`, or `ahp-chat:/&lt;uuid&gt;`)</summary>
/// <summary>The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/&lt;uuid&gt;`, `ahp-chat:/&lt;uuid&gt;`, or `ahp-canvas:/&lt;uuid&gt;`)</summary>
public required string Resource { get; init; }

/// <summary>The current state of the resource</summary>
Expand Down Expand Up @@ -7726,10 +7782,10 @@ public override void Write(Utf8JsonWriter writer, ToolInput value, JsonSerialize

/// <summary>
/// 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).
/// </summary>
[JsonConverter(typeof(SnapshotStateConverter))]
public sealed class SnapshotState
Expand All @@ -7743,6 +7799,9 @@ public sealed class SnapshotState
/// <summary>Chat state variant, when populated.</summary>
public ChatState? Chat { get; set; }

/// <summary>Canvas state variant, when populated.</summary>
public CanvasState? Canvas { get; set; }

/// <summary>Terminal state variant, when populated.</summary>
public TerminalState? Terminal { get; set; }

Expand Down Expand Up @@ -7791,6 +7850,12 @@ public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert
// session state was flattened.)
result.Session = root.Deserialize(AhpJsonTypeInfo.Get<SessionState>(options));
}
else if (root.TryGetProperty("instanceId", out _) &&
root.TryGetProperty("extensionId", out _) &&
root.TryGetProperty("canvasId", out _))
{
result.Canvas = root.Deserialize(AhpJsonTypeInfo.Get<CanvasState>(options));
}
else if (root.TryGetProperty("content", out _))
{
result.Terminal = root.Deserialize(AhpJsonTypeInfo.Get<TerminalState>(options));
Expand Down Expand Up @@ -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<AutomationRunState>(options)); return; }
if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get<AutomationState>(options)); return; }
if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get<ChatState>(options)); return; }
if (value.Canvas is not null) { JsonSerializer.Serialize(writer, value.Canvas, AhpJsonTypeInfo.Get<CanvasState>(options)); return; }
if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, AhpJsonTypeInfo.Get<SessionState>(options)); return; }
if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, AhpJsonTypeInfo.Get<TerminalState>(options)); return; }
if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, AhpJsonTypeInfo.Get<ChangesetState>(options)); return; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ public static class ProtocolVersion
/// The current protocol version (SemVer MAJOR.MINOR.PATCH) this
/// generated source speaks.
/// </summary>
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public sealed class MultiHostStateMirror
private readonly ConcurrentDictionary<HostId, RootState> _roots = new();
private readonly ConcurrentDictionary<HostedResourceKey, SessionState> _sessions = new();
private readonly ConcurrentDictionary<HostedResourceKey, ChatState> _chats = new();
private readonly ConcurrentDictionary<HostedResourceKey, CanvasState> _canvases = new();
private readonly ConcurrentDictionary<HostedResourceKey, TerminalState> _terminals = new();
private readonly ConcurrentDictionary<HostedResourceKey, ChangesetState> _changesets = new();

Expand Down Expand Up @@ -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);

/// <summary>Stores live canvas state under (hostId, uri).</summary>
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;
}

/// <summary>Returns canvas state at (hostId, uri), or (default, false).</summary>
public (CanvasState? Value, bool Found) Canvas(HostId hostId, string uri) =>
_canvases.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false);

/// <summary>Stores a terminal snapshot under (hostId, uri).</summary>
public void PutTerminal(HostId hostId, string uri, TerminalState state)
{
Expand Down Expand Up @@ -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 _);
}
Expand All @@ -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 _);
}
Expand Down
23 changes: 23 additions & 0 deletions clients/dotnet/src/AgentHostProtocol/Reducers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2100,6 +2103,26 @@ private static ReduceOutcome UpdateMcpServerCustomization(
return ReduceOutcome.NoOp;
}

/// <summary>Replaces live canvas state or returns OutOfScope.</summary>
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 ──────────────────────────────────────────────────

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ public void ReducerMatchesFixture(string name, string path)
case "chat":
RunFixture<ChatState>(initial, expected, actions, Reducers.ApplyToChat);
break;
case "canvas":
RunFixture<CanvasState>(initial, expected, actions, Reducers.ApplyToCanvas);
break;
case "automation":
RunFixture<AutomationState>(
initial,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading