diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs
index 9d0877928..532196948 100644
--- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs
+++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs
@@ -9,14 +9,19 @@ namespace Microsoft.AgentHostProtocol;
// ─── Enums ────────────────────────────────────────────────────────────
-/// Discriminant for reconnect result types.
-[JsonConverter(typeof(WireEnumConverter))]
-public enum ReconnectResultType
+/// Discriminant for per-channel reconnect recovery outcomes.
+[JsonConverter(typeof(WireEnumConverter))]
+public enum ChannelRecoveryKind
{
+ /// The server replayed the channel's missed actions.
[WireValue("replay")]
Replay,
+ /// The gap for this channel exceeded its replay buffer; a fresh snapshot is provided instead.
[WireValue("snapshot")]
Snapshot,
+ /// The channel can no longer be resumed (e.g. disposed, or no longer permitted).
+ [WireValue("missing")]
+ Missing,
}
/// How a new chat uses its source chat and turn.
@@ -523,8 +528,10 @@ public sealed record AutomationCustomizationsCapability
{
}
-/// Re-establishes a dropped connection. The server replays missed actions or
-/// provides fresh snapshots.
+/// Re-establishes a dropped connection. The server recovers each subscribed
+/// channel independently — some channels may replay, others may receive a
+/// fresh snapshot, and others may be reported missing, all in the same
+/// response (see {@link ChannelRecovery}).
public sealed record ReconnectParams
{
public required string Channel { get; init; }
@@ -538,39 +545,96 @@ public sealed record ReconnectParams
/// Client identifier from the original connection
public required string ClientId { get; init; }
- /// Last `serverSeq` the client received
+ /// Per-channel replay checkpoints for every channel the client is still subscribed to.
+ public required List Subscriptions { get; init; }
+}
+
+/// A single subscribed channel's replay checkpoint, carried in
+/// `ReconnectParams.subscriptions`.
+///
+/// Each subscription recovers independently from its own `lastSeenServerSeq`
+/// instead of one connection-wide watermark. A single shared watermark lets a
+/// fast-moving channel's `serverSeq` silently race ahead of a slower
+/// channel's — if channel A has an undelivered action at `serverSeq=100` and
+/// channel B goes on to deliver `serverSeq=101`, a connection-wide
+/// `lastSeenServerSeq=101` would skip A's action entirely on replay. Tracking
+/// one checkpoint per channel prevents that cross-channel skip without
+/// claiming any ordering *between* channels.
+public sealed record ChannelReplayCursor
+{
+ /// The subscribed channel URI.
+ public required string Channel { get; init; }
+
+ /// `serverSeq` of the last action the client fully applied or safely
+ /// retained for `channel`, or the `fromSeq` of the `Snapshot` the client
+ /// last used to initialize `channel` (see {@link Snapshot.fromSeq}).
+ ///
+ /// `0` means the client has no baseline for `channel` yet — e.g. it
+ /// subscribed but the `subscribe`/`initialize` response snapshot (if any)
+ /// never arrived before the connection dropped. The server MUST NOT use
+ /// another channel's progress to advance this checkpoint, and MUST NOT
+ /// use it to seed the connection's global `serverSeq` identity (see
+ /// {@link InitializeResult.serverSeq}).
public long LastSeenServerSeq { get; init; }
+}
- /// URIs the client was subscribed to
- public required List Subscriptions { get; init; }
+/// Result of the `reconnect` command.
+///
+/// The server MUST include all replayed and snapshotted data in the response
+/// before returning, and MUST include exactly one {@link ChannelRecovery} per
+/// channel named in `ReconnectParams.subscriptions`.
+public sealed record ReconnectResult
+{
+ /// One recovery outcome per requested subscription, in any order.
+ public required List Channels { get; init; }
}
-/// Reconnect result when the server can replay from the requested sequence.
+/// Recovery outcome for a channel that replayed cleanly.
///
-/// The server MUST include all replayed data in the response.
-public sealed record ReconnectReplayResult
+/// The server MUST include every action the channel missed since the
+/// matching `ChannelReplayCursor.lastSeenServerSeq`, in ascending `serverSeq`
+/// order, and MUST only include actions whose `ActionEnvelope.channel`
+/// equals `channel`.
+public sealed record ChannelReplayRecovery
{
/// Discriminant
- public ReconnectResultType Type { get; init; } = ReconnectResultType.Replay;
+ public ChannelRecoveryKind Kind { get; init; } = ChannelRecoveryKind.Replay;
+
+ /// The channel this recovery applies to.
+ public required string Channel { get; init; }
- /// Missed action envelopes since `lastSeenServerSeq`
+ /// Missed action envelopes since the requested `lastSeenServerSeq`.
public required List Actions { get; init; }
+}
+
+/// Recovery outcome for a channel whose gap exceeded its replay buffer.
+///
+/// Absent for stateless channels that have no state to snapshot; the server
+/// MUST instead use {@link ChannelReplayRecovery} with an empty `actions`
+/// list (or {@link ChannelMissingRecovery}, if the channel itself no longer
+/// exists) for those.
+public sealed record ChannelSnapshotRecovery
+{
+ /// Discriminant
+ public ChannelRecoveryKind Kind { get; init; } = ChannelRecoveryKind.Snapshot;
- /// URIs from `ReconnectParams.subscriptions` that the server cannot resume.
- /// This includes resources that no longer exist (e.g. disposed sessions or
- /// terminals) as well as resources the client is no longer permitted to
- /// observe. Clients SHOULD drop these from their local subscription set.
- public required List Missing { get; init; }
+ /// The channel this recovery applies to.
+ public required string Channel { get; init; }
+
+ /// Fresh snapshot the client MUST use as its new baseline for this channel.
+ public required Snapshot Snapshot { get; init; }
}
-/// Reconnect result when the gap exceeds the replay buffer.
-public sealed record ReconnectSnapshotResult
+/// Recovery outcome for a channel the server cannot resume — e.g. a disposed
+/// session or terminal, or a resource the client is no longer permitted to
+/// observe. Clients SHOULD drop `channel` from their local subscription set.
+public sealed record ChannelMissingRecovery
{
/// Discriminant
- public ReconnectResultType Type { get; init; } = ReconnectResultType.Snapshot;
+ public ChannelRecoveryKind Kind { get; init; } = ChannelRecoveryKind.Missing;
- /// Fresh snapshots for each subscription
- public required List Snapshots { get; init; }
+ /// The channel this recovery applies to.
+ public required string Channel { get; init; }
}
/// Subscribe to a URI-identified channel.
@@ -1939,29 +2003,30 @@ public sealed record FetchAutomationRunsResult
{
}
-// ─── ReconnectResult Union ────────────────────────────────────────────
+// ─── ChannelRecovery Union ────────────────────────────────────────────
-/// ReconnectResult is the result of the `reconnect` command.
-[JsonConverter(typeof(ReconnectResultConverter))]
-public sealed class ReconnectResult : AhpUnion
+/// ChannelRecovery is the per-channel reconnect recovery outcome.
+[JsonConverter(typeof(ChannelRecoveryConverter))]
+public sealed class ChannelRecovery : AhpUnion
{
- /// Creates an empty ReconnectResult (no active variant).
- public ReconnectResult() { }
+ /// Creates an empty ChannelRecovery (no active variant).
+ public ChannelRecovery() { }
- /// Creates a ReconnectResult wrapping the given variant value.
- public ReconnectResult(object? value) : base(value) { }
+ /// Creates a ChannelRecovery wrapping the given variant value.
+ public ChannelRecovery(object? value) : base(value) { }
}
-/// System.Text.Json converter for the ReconnectResult discriminated union.
-internal sealed class ReconnectResultConverter : UnionConverter
+/// System.Text.Json converter for the ChannelRecovery discriminated union.
+internal sealed class ChannelRecoveryConverter : UnionConverter
{
- public ReconnectResultConverter()
+ public ChannelRecoveryConverter()
: base(
- discriminator: "type",
+ discriminator: "kind",
variants: new Dictionary
{
- ["replay"] = typeof(ReconnectReplayResult),
- ["snapshot"] = typeof(ReconnectSnapshotResult),
+ ["replay"] = typeof(ChannelReplayRecovery),
+ ["snapshot"] = typeof(ChannelSnapshotRecovery),
+ ["missing"] = typeof(ChannelMissingRecovery),
},
allowUnknown: false)
{
diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs
index 5ee1af5c7..fd095d55c 100644
--- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs
+++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs
@@ -105,6 +105,12 @@ namespace Microsoft.AgentHostProtocol;
[JsonSerializable(typeof(ChangesetStatus))]
[JsonSerializable(typeof(ChangesetStatusChangedAction))]
[JsonSerializable(typeof(ChangesSummary))]
+[JsonSerializable(typeof(ChannelMissingRecovery))]
+[JsonSerializable(typeof(ChannelRecovery))]
+[JsonSerializable(typeof(ChannelRecoveryKind))]
+[JsonSerializable(typeof(ChannelReplayCursor))]
+[JsonSerializable(typeof(ChannelReplayRecovery))]
+[JsonSerializable(typeof(ChannelSnapshotRecovery))]
[JsonSerializable(typeof(ChatActivityChangedAction))]
[JsonSerializable(typeof(ChatBackgroundWorkRemovedAction))]
[JsonSerializable(typeof(ChatBackgroundWorkSetAction))]
@@ -288,10 +294,7 @@ namespace Microsoft.AgentHostProtocol;
[JsonSerializable(typeof(ProtectedResourceMetadata))]
[JsonSerializable(typeof(ReasoningResponsePart))]
[JsonSerializable(typeof(ReconnectParams))]
-[JsonSerializable(typeof(ReconnectReplayResult))]
[JsonSerializable(typeof(ReconnectResult))]
-[JsonSerializable(typeof(ReconnectResultType))]
-[JsonSerializable(typeof(ReconnectSnapshotResult))]
[JsonSerializable(typeof(ResolveSessionConfigParams))]
[JsonSerializable(typeof(ResolveSessionConfigResult))]
[JsonSerializable(typeof(ResourceChange))]
diff --git a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs
index e90eb47d0..e80439f6e 100644
--- a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs
+++ b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs
@@ -1049,20 +1049,28 @@ public async Task InitializeAsync(
}
/// Re-establishes a dropped connection via the reconnect flow.
+ /// Client identifier from the original connection.
+ ///
+ /// Per-channel replay checkpoints for every channel the client is still
+ /// subscribed to. Each channel recovers independently from its own
+ /// instead of one
+ /// connection-wide watermark, so a fast-moving channel can never cause a
+ /// slower channel's undelivered actions to be skipped (see
+ /// ).
+ ///
+ /// Cancellation token.
public async Task ReconnectAsync(
string clientId,
- long lastSeenServerSeq,
- IReadOnlyList? subscriptions = null,
+ IReadOnlyList? subscriptions = null,
CancellationToken cancellationToken = default)
{
var @params = new ReconnectParams
{
Channel = ProtocolVersion.RootResourceUri,
ClientId = clientId,
- LastSeenServerSeq = lastSeenServerSeq,
Subscriptions = subscriptions is not null
- ? new List(subscriptions)
- : new List(),
+ ? new List(subscriptions)
+ : new List(),
};
try
diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs
index 3d63486af..ac47d8330 100644
--- a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs
+++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs
@@ -40,6 +40,14 @@ internal sealed class HostEntry : IDisposable
private List _agents = new();
private long? _activeSessions;
private readonly List _subscriptions;
+ // Per-channel replay cursors: the highest `serverSeq` fully applied (or
+ // snapshot baseline established) for each tracked channel. Keyed by
+ // channel URI. Drives the `subscriptions` sent on `AhpClient.ReconnectAsync`
+ // and is only ever advanced by envelopes or snapshots for the matching
+ // channel — never cross-channel. `_serverSeq` below stays purely
+ // informational/diagnostic (surfaced on `HostHandle.ServerSeq`) and MUST
+ // NOT be used to decide reconnect eligibility or what to replay.
+ private readonly Dictionary _channelCursors = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private long _serverSeq;
private DateTimeOffset? _lastConnectedAt;
@@ -381,24 +389,114 @@ public void ApplySummaryChange(string uri, PartialSessionSummary changes)
}
}
- /// Tracks a URI in the replay subscription set (idempotent).
- public void AppendSubscription(string uri)
+ ///
+ /// Tracks a URI in the replay subscription set (idempotent). If
+ /// is supplied (e.g. a subscribe
+ /// response's snapshot.fromSeq ), seeds — or raises — the channel's
+ /// replay cursor from it. Never lowers an already-tracked cursor: a
+ /// re-subscribe shouldn't rewind a channel that already caught up further
+ /// via reconnect/replay.
+ ///
+ public void AppendSubscription(string uri, long? baselineServerSeq = null)
{
- lock (_gate) { if (!_subscriptions.Contains(uri)) _subscriptions.Add(uri); }
+ lock (_gate)
+ {
+ if (!_subscriptions.Contains(uri)) _subscriptions.Add(uri);
+ if (!_channelCursors.TryGetValue(uri, out var current))
+ {
+ _channelCursors[uri] = baselineServerSeq ?? 0;
+ }
+ else if (baselineServerSeq is { } baseline && baseline > current)
+ {
+ _channelCursors[uri] = baseline;
+ }
+ }
}
- /// Drops a URI from the replay subscription set.
+ /// Drops a URI from the replay subscription set and its replay cursor.
public void RemoveSubscription(string uri)
{
- lock (_gate) { _subscriptions.Remove(uri); }
+ lock (_gate) { _subscriptions.Remove(uri); _channelCursors.Remove(uri); }
}
- /// Drops URIs that the server could not resume.
+ /// Drops URIs (and their replay cursors) that the server could not resume.
public void RemoveSubscriptions(IEnumerable uris)
{
lock (_gate)
{
- foreach (var uri in uris) _subscriptions.Remove(uri);
+ foreach (var uri in uris)
+ {
+ _subscriptions.Remove(uri);
+ _channelCursors.Remove(uri);
+ }
+ }
+ }
+
+ ///
+ /// Builds the per-channel replay cursors for every currently tracked
+ /// subscription, for a reconnect request's subscriptions
+ /// field. A channel with no tracked cursor sends 0 .
+ ///
+ public List BuildReplayCursors()
+ {
+ lock (_gate)
+ {
+ var list = new List(_subscriptions.Count);
+ foreach (var uri in _subscriptions)
+ {
+ list.Add(new ChannelReplayCursor
+ {
+ Channel = uri,
+ LastSeenServerSeq = _channelCursors.TryGetValue(uri, out var seq) ? seq : 0,
+ });
+ }
+ return list;
+ }
+ }
+
+ /// Returns the tracked replay cursor for , or 0 if untracked.
+ public long GetChannelCursor(string channel)
+ {
+ lock (_gate) { return _channelCursors.TryGetValue(channel, out var seq) ? seq : 0; }
+ }
+
+ ///
+ /// Advances only 's replay cursor to
+ /// , never moving it backward. Never touches
+ /// another channel's cursor, so a fast channel can never cause a slower
+ /// channel's undelivered actions to be skipped on the next reconnect.
+ ///
+ public void AdvanceChannelCursor(string channel, long serverSeq)
+ {
+ lock (_gate)
+ {
+ if (!_channelCursors.TryGetValue(channel, out var current) || serverSeq > current)
+ _channelCursors[channel] = serverSeq;
+ }
+ }
+
+ ///
+ /// Sets 's replay cursor to an explicit baseline
+ /// (a fresh snapshot's fromSeq ), overwriting any prior value — the
+ /// snapshot is the new baseline regardless of what came before.
+ ///
+ public void SetChannelCursor(string channel, long serverSeq)
+ {
+ lock (_gate) { _channelCursors[channel] = serverSeq; }
+ }
+
+ ///
+ /// Clears every tracked replay cursor and reseeds it from a fresh set of
+ /// per-channel snapshots (a full initialize/reinitialize has no continuity
+ /// guarantee for prior progress, so every subscribed channel re-baselines
+ /// from its fresh snapshot, or stays unset — i.e. 0 — if stateless).
+ ///
+ public void ResetChannelCursors(IEnumerable snapshots)
+ {
+ lock (_gate)
+ {
+ _channelCursors.Clear();
+ foreach (var snapshot in snapshots) _channelCursors[snapshot.Resource] = snapshot.FromSeq;
}
}
@@ -843,11 +941,13 @@ private async Task OpenHostCoreAsync(
// race initialize/reconnect are buffered rather than discarded.
var stream = client.CreateEventStream();
- // On a reconnect with a known serverSeq, issue the AHP `reconnect` command
- // (clientId + lastSeenServerSeq) so the host REPLAYS the actions missed
- // while disconnected, instead of re-initializing from scratch. Mirrors
- // Swift's HostRuntime reconnect path. Falls back to a fresh `initialize`
- // on the still-live client if the host rejects reconnect.
+ // On a reconnect, issue the AHP `reconnect` command (clientId +
+ // per-channel replay cursors) so the host recovers each subscribed
+ // channel INDEPENDENTLY — replay, snapshot, or missing, all in one
+ // response — instead of re-initializing from scratch. Mirrors
+ // Swift's HostRuntime reconnect path. Falls back to a fresh
+ // `initialize` on the still-live client if the host rejects
+ // reconnect.
var subscriptions = entry.Config.InitialSubscriptions;
if (isReconnect)
{
@@ -857,7 +957,7 @@ private async Task OpenHostCoreAsync(
try
{
reconnectResult = await client.ReconnectAsync(
- snap.ClientId, snap.ServerSeq, subscriptions, cancellationToken)
+ snap.ClientId, entry.BuildReplayCursors(), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
@@ -869,7 +969,7 @@ private async Task OpenHostCoreAsync(
// fallback initialize.
}
- if (reconnectResult?.Value is ReconnectReplayResult replay)
+ if (reconnectResult is not null)
{
var summaries = await FetchSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
@@ -881,30 +981,7 @@ private async Task OpenHostCoreAsync(
() =>
{
if (summaries is not null) entry.SeedSessionSummaries(summaries);
- ApplyReconnectReplay(entry, replay);
- });
- installed = true;
- }
- finally
- {
- entry.ConnectionGate.Release();
- }
- return;
- }
-
- if (reconnectResult?.Value is ReconnectSnapshotResult snapshot)
- {
- var summaries = await FetchSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false);
- cancellationToken.ThrowIfCancellationRequested();
- await entry.ConnectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
- {
- attempt.BeginCommit(cancellationToken);
- InstallOpenHost(entry, client, snap.ProtocolVersion, stream,
- () =>
- {
- if (summaries is not null) entry.SeedSessionSummaries(summaries);
- ApplyReconnectSnapshot(entry, snapshot);
+ ApplyReconnectResult(entry, reconnectResult);
});
installed = true;
}
@@ -939,6 +1016,12 @@ private async Task OpenHostCoreAsync(
{
if (initialSummaries is not null) entry.SeedSessionSummaries(initialSummaries);
entry.ApplyConnected(root, result.ServerSeq);
+ // A full (re)initialize discards any prior per-channel
+ // progress — the server has no continuity guarantee for
+ // it — and re-baselines every subscribed channel from
+ // its fresh snapshot (or stays unset, i.e. `0`, for
+ // stateless channels with none).
+ entry.ResetChannelCursors(result.Snapshots);
});
installed = true;
}
@@ -1009,87 +1092,122 @@ private void CompleteOpenHost(HostEntry entry, EventStream stream)
}
///
- /// Applies a reconnect-replay result: bumps the host generation (a reconnect
- /// happened) + advances the serverSeq to the last replayed envelope, and fans
- /// every replayed action out exactly like the live pump (host-state mirror +
- /// global subscription fan-in + per-(host,uri) listeners) so consumers that
- /// subscribed before the drop observe the actions missed while disconnected.
- /// URIs in Missing are pruned from the reconnect subscription set.
+ /// Applies a reconnect result: bumps the host generation (a reconnect
+ /// happened) and processes each channel's independent recovery outcome —
+ /// replay, snapshot, or missing — exactly like the live pump (host-state
+ /// mirror + global subscription fan-in + per-(host,uri) listeners) so
+ /// consumers that subscribed before the drop observe the actions/snapshots
+ /// missed while disconnected.
+ ///
+ /// Each requested channel gets exactly one independent recovery outcome —
+ /// never a single connection-wide result — so a fast channel's progress
+ /// can't cause a slower channel's undelivered actions to be skipped (or a
+ /// missing channel to hide another channel's replay). Only the matching
+ /// channel's replay cursor is ever advanced; the entry's informational,
+ /// connection-wide serverSeq high-water mark is bumped from
+ /// whichever channel advanced furthest, purely for diagnostics.
///
- private ulong ApplyReconnectReplay(HostEntry entry, ReconnectReplayResult replay)
- {
- var initialSeq = entry.Snapshot().ServerSeq;
- var previousSeq = initialSeq;
- if (replay.Actions is { } seqScan)
+ private ulong ApplyReconnectResult(HostEntry entry, ReconnectResult result)
+ {
+ // The informational serverSeq starts from its last known value and is
+ // only bumped upfront from Snapshot recoveries here (setting state
+ // directly can't throw partway through). Replay recoveries are
+ // intentionally NOT folded in here — they're applied action-by-action
+ // below, advancing serverSeq only as each action actually commits, so
+ // a mid-replay failure (e.g. a malformed action) leaves serverSeq at
+ // the last action that truly applied rather than a value promised in
+ // advance.
+ var lastSeq = entry.Snapshot().ServerSeq;
+ RootState? root = null;
+ var missingChannels = new List();
+
+ // Pre-scan: every channel's replay actions MUST be strictly ascending
+ // past that channel's own cursor — never another channel's — and
+ // collect the root snapshot (if any) before committing the connect.
+ foreach (var recovery in result.Channels)
{
- foreach (var env in seqScan)
+ switch (recovery.Value)
{
- if (env.ServerSeq <= previousSeq)
- throw new AhpTransportException(
- "protocol",
- $"ahp: reconnect replay sequence {env.ServerSeq} did not advance past {previousSeq}");
- previousSeq = env.ServerSeq;
+ case ChannelReplayRecovery replay:
+ {
+ var previousSeq = entry.GetChannelCursor(replay.Channel);
+ foreach (var env in replay.Actions)
+ {
+ if (env.ServerSeq <= previousSeq)
+ throw new AhpTransportException(
+ "protocol",
+ $"ahp: reconnect replay sequence {env.ServerSeq} did not advance past {previousSeq} for channel '{replay.Channel}'");
+ previousSeq = env.ServerSeq;
+ }
+ break;
+ }
+ case ChannelSnapshotRecovery snapshotRecovery:
+ if (snapshotRecovery.Snapshot.FromSeq > lastSeq) lastSeq = snapshotRecovery.Snapshot.FromSeq;
+ if (snapshotRecovery.Channel == ProtocolVersion.RootResourceUri
+ && snapshotRecovery.Snapshot.State?.Root is { } rootState)
+ root = rootState;
+ break;
+ case ChannelMissingRecovery missingRecovery:
+ missingChannels.Add(missingRecovery.Channel);
+ break;
}
}
- var generation = entry.ApplyConnected(null, initialSeq);
+ var generation = entry.ApplyConnected(root, lastSeq);
- if (replay.Actions is { } actions)
+ foreach (var recovery in result.Channels)
{
- foreach (var env in actions)
+ switch (recovery.Value)
{
- var evt = new SubscriptionEventAction(env);
- ApplyEventToHostState(entry, evt);
- if (env.Channel == ProtocolVersion.RootResourceUri)
- entry.ApplyRootAction(env.Action);
- entry.AdvanceServerSeq(env.ServerSeq);
- var hostEv = new HostSubscriptionEvent(entry.Id, env.Channel, evt);
- List>? channels;
- lock (_subsLock)
+ case ChannelReplayRecovery replay:
+ foreach (var env in replay.Actions)
+ {
+ var evt = new SubscriptionEventAction(env);
+ ApplyEventToHostState(entry, evt);
+ if (env.Channel == ProtocolVersion.RootResourceUri)
+ entry.ApplyRootAction(env.Action);
+ entry.AdvanceServerSeq(env.ServerSeq);
+ // Advance only the matching channel's cursor — never
+ // another channel's.
+ entry.AdvanceChannelCursor(env.Channel, env.ServerSeq);
+ BroadcastSubscriptionEvent(entry.Id, env.Channel, evt);
+ }
+ break;
+ case ChannelSnapshotRecovery snapshotRecovery:
{
- channels = _subChannels.Count == 0
- ? null
- : new List>(_subChannels);
+ // The snapshot IS the new baseline for this channel, so the
+ // cursor is set directly rather than maxed against any
+ // prior value.
+ entry.SetChannelCursor(snapshotRecovery.Channel, snapshotRecovery.Snapshot.FromSeq);
+ var evt = new SubscriptionEventSnapshot(snapshotRecovery.Snapshot);
+ BroadcastSubscriptionEvent(entry.Id, snapshotRecovery.Channel, evt);
+ break;
}
- if (channels is not null)
- foreach (var ch in channels) ch.Writer.TryWrite(hostEv);
- BroadcastPerResourceEvent(entry.Id, env.Channel, evt);
+ case ChannelMissingRecovery:
+ // Handled below via entry.RemoveSubscriptions, which also
+ // drops the (now meaningless) cursor entry.
+ break;
}
}
- entry.RemoveSubscriptions(replay.Missing);
+
+ if (missingChannels.Count > 0) entry.RemoveSubscriptions(missingChannels);
return generation;
}
- ///
- /// Applies a reconnect snapshot without issuing a second initialize. The
- /// every returned resource snapshot is published before the host transitions
- /// to connected. The subscription set is retained because stateless
- /// subscriptions intentionally have no snapshot.
- ///
- private ulong ApplyReconnectSnapshot(HostEntry entry, ReconnectSnapshotResult result)
+ /// Fans a subscription event out to the global subscription stream and per-(host,uri) listeners.
+ private void BroadcastSubscriptionEvent(HostId hostId, string channel, SubscriptionEvent evt)
{
- var lastSeq = entry.Snapshot().ServerSeq;
- foreach (var snapshot in result.Snapshots)
- {
- if (snapshot.FromSeq > lastSeq) lastSeq = snapshot.FromSeq;
- }
- var generation = entry.ApplyConnected(ExtractRootSnapshot(result.Snapshots), lastSeq);
- foreach (var snapshot in result.Snapshots)
+ var hostEv = new HostSubscriptionEvent(hostId, channel, evt);
+ List>? channels;
+ lock (_subsLock)
{
- var evt = new SubscriptionEventSnapshot(snapshot);
- var hostEv = new HostSubscriptionEvent(entry.Id, snapshot.Resource, evt);
- List>? channels;
- lock (_subsLock)
- {
- channels = _subChannels.Count == 0
- ? null
- : new List>(_subChannels);
- }
- if (channels is not null)
- foreach (var ch in channels) ch.Writer.TryWrite(hostEv);
- BroadcastPerResourceEvent(entry.Id, snapshot.Resource, evt);
+ channels = _subChannels.Count == 0
+ ? null
+ : new List>(_subChannels);
}
- return generation;
+ if (channels is not null)
+ foreach (var ch in channels) ch.Writer.TryWrite(hostEv);
+ BroadcastPerResourceEvent(hostId, channel, evt);
}
/// Pulls root state out of a snapshot collection, if present.
@@ -1162,7 +1280,12 @@ private async Task PumpEventsAsync(HostEntry entry, EventStream stream)
&& rootAction.Envelope.Channel == ProtocolVersion.RootResourceUri
&& entry.ApplyRootAction(rootAction.Envelope.Action);
if (ev.Event is SubscriptionEventAction action)
+ {
entry.AdvanceServerSeq(action.Envelope.ServerSeq);
+ // Advance only this envelope's channel cursor — never let
+ // one channel's progress advance another channel's.
+ entry.AdvanceChannelCursor(action.Envelope.Channel, action.Envelope.ServerSeq);
+ }
var hostEv = new HostSubscriptionEvent(entry.Id, ev.Channel, ev.Event);
List>? channels;
@@ -1802,7 +1925,7 @@ public async Task SubscribeAsync(
{
if (ReferenceEquals(client, entry.CurrentClient))
{
- entry.AppendSubscription(uri);
+ entry.AppendSubscription(uri, result.Snapshot?.FromSeq);
return result;
}
}
diff --git a/clients/dotnet/src/AgentHostProtocol/IAhpClient.cs b/clients/dotnet/src/AgentHostProtocol/IAhpClient.cs
index 881be4bc2..42dd580ef 100644
--- a/clients/dotnet/src/AgentHostProtocol/IAhpClient.cs
+++ b/clients/dotnet/src/AgentHostProtocol/IAhpClient.cs
@@ -57,10 +57,15 @@ Task InitializeAsync(
CancellationToken cancellationToken = default);
/// Re-establishes a dropped connection via the reconnect flow.
+ /// Client identifier from the original connection.
+ ///
+ /// Per-channel replay checkpoints for every channel the client is still
+ /// subscribed to (see ).
+ ///
+ /// Cancellation token.
Task ReconnectAsync(
string clientId,
- long lastSeenServerSeq,
- IReadOnlyList? subscriptions = null,
+ IReadOnlyList? subscriptions = null,
CancellationToken cancellationToken = default);
///
diff --git a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs
index b61c619ec..98d790ea8 100644
--- a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs
+++ b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs
@@ -113,22 +113,31 @@ static async Task RunTransportScenarioAsync(SystemTextJsonAhpSerializer serializ
serverSide,
serializer,
"reconnect",
- new ReconnectResult(new ReconnectReplayResult
+ new ReconnectResult
{
- Type = ReconnectResultType.Replay,
- Actions = new List(),
- Missing = new List(),
- }),
+ Channels = new List
+ {
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = envelopeChannel,
+ Actions = new List(),
+ }),
+ },
+ },
cancellationToken);
ReconnectResult reconnectResult = await client.ReconnectAsync(
"native-aot-client",
- lastSeenServerSeq: 4,
- subscriptions: new[] { envelopeChannel },
+ subscriptions: new[]
+ {
+ new ChannelReplayCursor { Channel = envelopeChannel, LastSeenServerSeq = 4 },
+ },
cancellationToken);
JsonRpcRequest reconnectRequest = await reconnectResponse;
- Ensure(reconnectResult.Value is ReconnectReplayResult, "Reconnect replay result failed.");
Ensure(
- reconnectRequest.Params?.GetProperty("lastSeenServerSeq").GetInt64() == 4,
+ reconnectResult.Channels is [{ Value: ChannelReplayRecovery }],
+ "Reconnect replay result failed.");
+ Ensure(
+ reconnectRequest.Params?.GetProperty("subscriptions")[0].GetProperty("lastSeenServerSeq").GetInt64() == 4,
"Reconnect params failed.");
Task pingResponse = RespondToRequestAsync(
diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs
index 37c6aebd0..ca6e5f2e7 100644
--- a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs
+++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs
@@ -76,8 +76,9 @@ private static Task RunFakeServerWithActionAsync(
.AfterInitialize((side, c) => RepeatActionAsync(side, actionChannel, serverSeq, c))
.OnReconnect((req, side, c) =>
{
- var replay = new ReconnectReplayResult
+ var replay = new ChannelReplayRecovery
{
+ Channel = actionChannel,
Actions = new List
{
new ActionEnvelope
@@ -91,9 +92,12 @@ private static Task RunFakeServerWithActionAsync(
}),
},
},
- Missing = new List(),
};
- return FakeHost.RespondResultAsync(side, req.Id, new ReconnectResult(replay), c);
+ var result = new ReconnectResult
+ {
+ Channels = new List { new ChannelRecovery(replay) },
+ };
+ return FakeHost.RespondResultAsync(side, req.Id, result, c);
})
.RunAsync(serverSide, ct);
@@ -377,16 +381,22 @@ public async Task MultiHost_Reconnect_UsesLiveSubscriptionsAndServerSequence()
{
reconnectParams.TrySetResult(
Ser.Deserialize(request.Params!.Value.GetRawText()));
- return FakeHost.RespondResultAsync(
- side,
- request.Id,
- new ReconnectResult(new ReconnectReplayResult
+ var result = new ReconnectResult
+ {
+ Channels = new List
{
- Type = ReconnectResultType.Replay,
- Actions = new List(),
- Missing = new List { "copilot:/dynamic" },
- }),
- token);
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = ProtocolVersion.RootResourceUri,
+ Actions = new List(),
+ }),
+ new ChannelRecovery(new ChannelMissingRecovery
+ {
+ Channel = "copilot:/dynamic",
+ }),
+ },
+ };
+ return FakeHost.RespondResultAsync(side, request.Id, result, token);
})
.OnListSessions((request, side, token) =>
RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
@@ -426,10 +436,15 @@ await WaitUntilAsync(
cts.Token,
8000);
- Assert.Equal(41, observed.LastSeenServerSeq);
+ Assert.Equal(
+ 41,
+ observed.Subscriptions.Single(s => s.Channel == ProtocolVersion.RootResourceUri).LastSeenServerSeq);
+ Assert.Equal(
+ 0,
+ observed.Subscriptions.Single(s => s.Channel == "copilot:/dynamic").LastSeenServerSeq);
Assert.Equal(
new[] { ProtocolVersion.RootResourceUri, "copilot:/dynamic" },
- observed.Subscriptions);
+ observed.Subscriptions.Select(s => s.Channel));
Assert.DoesNotContain("copilot:/dynamic", m.Host(new HostId("h"))!.Subscriptions);
}
@@ -468,12 +483,17 @@ public async Task MultiHost_ReconnectReplay_InstallsReplacementBeforePublishingA
FakeHost.RespondResultAsync(
side,
request.Id,
- new ReconnectResult(new ReconnectReplayResult
+ new ReconnectResult
{
- Type = ReconnectResultType.Replay,
- Actions = actions,
- Missing = new List(),
- }),
+ Channels = new List
+ {
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = ProtocolVersion.RootResourceUri,
+ Actions = actions,
+ }),
+ },
+ },
token));
}
_ = host.RunAsync(server, cts.Token);
@@ -540,10 +560,9 @@ public async Task MultiHost_ReconnectReplay_AdvancesSequenceOnlyAfterEachApplied
Ser.Deserialize(request.Params!.Value.GetRawText()));
}
- var replay = new ReconnectReplayResult
+ var replay = new ChannelReplayRecovery
{
- Type = ReconnectResultType.Replay,
- Missing = new List(),
+ Channel = ProtocolVersion.RootResourceUri,
Actions = currentAttempt == 2
? new List
{
@@ -573,7 +592,10 @@ public async Task MultiHost_ReconnectReplay_AdvancesSequenceOnlyAfterEachApplied
return FakeHost.RespondResultAsync(
side,
request.Id,
- new ReconnectResult(replay),
+ new ReconnectResult
+ {
+ Channels = new List { new ChannelRecovery(replay) },
+ },
token);
});
}
@@ -587,6 +609,7 @@ await m.AddHostAsync(new HostConfig
{
Id = new HostId("h"),
TransportFactory = factory,
+ InitialSubscriptions = new[] { ProtocolVersion.RootResourceUri },
ReconnectPolicy = new ReconnectPolicy
{
InitialBackoff = TimeSpan.FromMilliseconds(1),
@@ -607,7 +630,12 @@ await WaitForHostStateAsync(
await m.ReconnectAsync(new HostId("h"), cts.Token);
var observed = await thirdAttemptParams.Task.WaitAsync(cts.Token);
- Assert.Equal(10, observed.LastSeenServerSeq);
+ // The root channel's replay cursor persisted across the FAILED second
+ // attempt at exactly the last action that truly committed (seq 10),
+ // not the seq 11 action whose apply threw.
+ Assert.Equal(
+ 10,
+ observed.Subscriptions.Single(s => s.Channel == ProtocolVersion.RootResourceUri).LastSeenServerSeq);
}
[Fact]
@@ -630,41 +658,48 @@ public async Task MultiHost_ReconnectSnapshot_AppliesStateAndRetainsStatelessSub
FakeHost.RespondResultAsync(
side,
request.Id,
- new ReconnectResult(new ReconnectSnapshotResult
+ new ReconnectResult
{
- Type = ReconnectResultType.Snapshot,
- Snapshots = new List
+ Channels = new List
{
- new()
+ new ChannelRecovery(new ChannelSnapshotRecovery
{
- Resource = ProtocolVersion.RootResourceUri,
- FromSeq = 77,
- State = new SnapshotState
+ Channel = ProtocolVersion.RootResourceUri,
+ Snapshot = new Snapshot
{
- Root = new RootState
+ Resource = ProtocolVersion.RootResourceUri,
+ FromSeq = 77,
+ State = new SnapshotState
{
- Agents = new List(),
- ActiveSessions = 9,
+ Root = new RootState
+ {
+ Agents = new List(),
+ ActiveSessions = 9,
+ },
},
},
- },
- new()
+ }),
+ new ChannelRecovery(new ChannelSnapshotRecovery
{
- Resource = sessionResource,
- FromSeq = 78,
- State = new SnapshotState
+ Channel = sessionResource,
+ Snapshot = new Snapshot
{
- Session = new SessionState
+ Resource = sessionResource,
+ FromSeq = 78,
+ State = new SnapshotState
{
- Provider = "test",
- Title = "Restored session",
- ActiveClients = new List(),
- Chats = new List(),
+ Session = new SessionState
+ {
+ Provider = "test",
+ Title = "Restored session",
+ ActiveClients = new List(),
+ Chats = new List(),
+ },
},
},
- },
+ }),
},
- }),
+ },
token))
.OnListSessions((request, side, token) =>
RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
@@ -778,12 +813,17 @@ public async Task MultiHost_SubscribeDuringReconnect_TargetsReplacementConnectio
await FakeHost.RespondResultAsync(
side,
request.Id,
- new ReconnectResult(new ReconnectReplayResult
+ new ReconnectResult
{
- Type = ReconnectResultType.Replay,
- Actions = new List(),
- Missing = new List(),
- }),
+ Channels = new List
+ {
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = ProtocolVersion.RootResourceUri,
+ Actions = new List(),
+ }),
+ },
+ },
token);
})
.OnListSessions((request, side, token) =>
@@ -938,24 +978,33 @@ private static Task RunReconnectFakeServerAsync(
.OnListSessions((req, side, c) => RespondListSessionsAsync(side, req.Id, Array.Empty(), c))
.OnReconnect((req, side, c) =>
{
- var replay = new ReconnectReplayResult
+ var channels = new List
{
- Actions = new List
+ new ChannelRecovery(new ChannelReplayRecovery
{
- new ActionEnvelope
+ Channel = ProtocolVersion.RootResourceUri,
+ Actions = new List
{
- Channel = ProtocolVersion.RootResourceUri,
- ServerSeq = replaySeq,
- Action = new StateAction(new RootActiveSessionsChangedAction
+ new ActionEnvelope
{
- Type = ActionType.RootActiveSessionsChanged,
- ActiveSessions = 7,
- }),
+ Channel = ProtocolVersion.RootResourceUri,
+ ServerSeq = replaySeq,
+ Action = new StateAction(new RootActiveSessionsChangedAction
+ {
+ Type = ActionType.RootActiveSessionsChanged,
+ ActiveSessions = 7,
+ }),
+ },
},
- },
- Missing = new List(missing),
+ }),
};
- return FakeHost.RespondResultAsync(side, req.Id, new ReconnectResult(replay), c);
+ channels.AddRange(missing.Select(uri =>
+ new ChannelRecovery(new ChannelMissingRecovery { Channel = uri })));
+ return FakeHost.RespondResultAsync(
+ side,
+ req.Id,
+ new ReconnectResult { Channels = channels },
+ c);
})
.AckUnmatchedWithEmpty()
.RunAsync(serverSide, ct);
@@ -3087,4 +3136,408 @@ await WaitUntilAsync(() =>
return seqs.Count == 2 && seqs[0] == 42 && seqs[1] == 43;
}, cts.Token, 8000);
}
+
+ // ── Per-channel replay cursor regression tests ──────────────────────────
+ // Ports of clients/typescript/test/hosts.test.ts's four newest tests.
+ // These prove the actual bug the per-channel cursor design fixes: a
+ // single connection-wide watermark would let one channel's progress
+ // silently skip another channel's undelivered actions on reconnect.
+
+ [Fact]
+ public async Task MultiHost_Reconnect_SendsIndependentPerChannelCursors_FastChannelNeverAdvancesSlowOne()
+ {
+ const string channelA = "ahp-canvas:/a";
+ const string channelB = "ahp-canvas:/b";
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
+ var reconnectParams = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ MemTransport? firstServer = null;
+ var attempt = 0;
+
+ HostTransportFactory factory = (id, ct) =>
+ {
+ var (client, server) = MemTransport.CreatePair();
+ if (Interlocked.Increment(ref attempt) == 1)
+ {
+ firstServer = server;
+ _ = Task.Run(() => FakeHost.New()
+ .OnInitialize((request, side, token) =>
+ RespondInitializeWithRootAsync(side, request.Id, null, 0, token))
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .On("subscribe", (request, side, token) =>
+ FakeHost.RespondResultAsync(side, request.Id, new SubscribeResult(), token))
+ .RunAsync(server, cts.Token));
+ }
+ else
+ {
+ _ = Task.Run(() => FakeHost.New()
+ .OnReconnect((request, side, token) =>
+ {
+ reconnectParams.TrySetResult(
+ Ser.Deserialize(request.Params!.Value.GetRawText()));
+ var result = new ReconnectResult
+ {
+ Channels = new List
+ {
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = channelA,
+ Actions = new List(),
+ }),
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = channelB,
+ Actions = new List(),
+ }),
+ },
+ };
+ return FakeHost.RespondResultAsync(side, request.Id, result, token);
+ })
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .RunAsync(server, cts.Token));
+ }
+ return Task.FromResult(client);
+ };
+
+ var m = new MultiHostClient();
+ await using var _mh = m;
+ await m.AddHostAsync(new HostConfig
+ {
+ Id = new HostId("h"),
+ TransportFactory = factory,
+ ReconnectPolicy = new ReconnectPolicy
+ {
+ InitialBackoff = TimeSpan.FromMilliseconds(20),
+ MaxBackoff = TimeSpan.FromMilliseconds(20),
+ BackoffMultiplier = 1,
+ },
+ }, cts.Token);
+
+ await m.SubscribeAsync(new HostId("h"), channelA, cts.Token);
+ await m.SubscribeAsync(new HostId("h"), channelB, cts.Token);
+
+ // B races ahead to serverSeq=101 while A never receives anything. A
+ // single connection-wide watermark would send `lastSeenServerSeq: 101`
+ // on the next reconnect and silently skip any of A's undelivered
+ // actions below that value; per-channel cursors must keep A at its
+ // own baseline instead.
+ await SendActionAsync(firstServer!, channelB, 101, cts.Token);
+ await WaitUntilAsync(() => m.Host(new HostId("h"))?.ServerSeq == 101, cts.Token);
+
+ await m.ReconnectAsync(new HostId("h"), cts.Token);
+ var observed = await reconnectParams.Task.WaitAsync(cts.Token);
+
+ Assert.Equal(
+ 101,
+ observed.Subscriptions.Single(s => s.Channel == channelB).LastSeenServerSeq);
+ Assert.Equal(
+ 0,
+ observed.Subscriptions.Single(s => s.Channel == channelA).LastSeenServerSeq);
+ }
+
+ [Fact]
+ public async Task MultiHost_ChannelReplayRecovery_AdvancesOnlyThatChannelsCursor()
+ {
+ const string channelA = "ahp-canvas:/a";
+ const string channelB = "ahp-canvas:/b";
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
+ var secondReconnectParams = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var attempt = 0;
+
+ HostTransportFactory factory = (id, ct) =>
+ {
+ var (client, server) = MemTransport.CreatePair();
+ var currentAttempt = Interlocked.Increment(ref attempt);
+ if (currentAttempt == 1)
+ {
+ _ = Task.Run(() => FakeHost.New()
+ .OnInitialize((request, side, token) =>
+ RespondInitializeWithRootAsync(side, request.Id, null, 0, token))
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .On("subscribe", (request, side, token) =>
+ FakeHost.RespondResultAsync(side, request.Id, new SubscribeResult(), token))
+ .RunAsync(server, cts.Token));
+ }
+ else
+ {
+ _ = Task.Run(() => FakeHost.New()
+ .OnReconnect((request, side, token) =>
+ {
+ if (currentAttempt == 3)
+ {
+ secondReconnectParams.TrySetResult(
+ Ser.Deserialize(request.Params!.Value.GetRawText()));
+ }
+
+ // On the first reconnect, A exhausts a 2-action replay
+ // while B gets an empty replay. The *next* reconnect
+ // must report A's advanced cursor (11) while B — which
+ // had no actions — stays at 0.
+ var channels = new List
+ {
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = channelA,
+ Actions = currentAttempt == 2
+ ? new List
+ {
+ new()
+ {
+ Channel = channelA,
+ ServerSeq = 10,
+ Action = new StateAction(new SessionTitleChangedAction
+ {
+ Type = ActionType.SessionTitleChanged,
+ Title = "a-10",
+ }),
+ },
+ new()
+ {
+ Channel = channelA,
+ ServerSeq = 11,
+ Action = new StateAction(new SessionTitleChangedAction
+ {
+ Type = ActionType.SessionTitleChanged,
+ Title = "a-11",
+ }),
+ },
+ }
+ : new List(),
+ }),
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = channelB,
+ Actions = new List(),
+ }),
+ };
+ return FakeHost.RespondResultAsync(
+ side, request.Id, new ReconnectResult { Channels = channels }, token);
+ })
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .RunAsync(server, cts.Token));
+ }
+ return Task.FromResult(client);
+ };
+
+ var m = new MultiHostClient();
+ await using var _mh = m;
+ await m.AddHostAsync(new HostConfig
+ {
+ Id = new HostId("h"),
+ TransportFactory = factory,
+ ReconnectPolicy = new ReconnectPolicy
+ {
+ InitialBackoff = TimeSpan.FromMilliseconds(1),
+ MaxBackoff = TimeSpan.FromMilliseconds(1),
+ BackoffMultiplier = 1,
+ },
+ }, cts.Token);
+
+ await m.SubscribeAsync(new HostId("h"), channelA, cts.Token);
+ await m.SubscribeAsync(new HostId("h"), channelB, cts.Token);
+
+ await m.ReconnectAsync(new HostId("h"), cts.Token);
+ await WaitUntilAsync(() => m.Host(new HostId("h"))?.ServerSeq == 11, cts.Token);
+
+ await m.ReconnectAsync(new HostId("h"), cts.Token);
+ var observed = await secondReconnectParams.Task.WaitAsync(cts.Token);
+
+ Assert.Equal(
+ 11,
+ observed.Subscriptions.Single(s => s.Channel == channelA).LastSeenServerSeq);
+ Assert.Equal(
+ 0,
+ observed.Subscriptions.Single(s => s.Channel == channelB).LastSeenServerSeq);
+ }
+
+ [Fact]
+ public async Task MultiHost_ChannelSnapshotRecovery_SetsCursorFromFromSeqBaseline()
+ {
+ const string channelA = "ahp-canvas:/a";
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
+ var secondReconnectParams = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var attempt = 0;
+
+ HostTransportFactory factory = (id, ct) =>
+ {
+ var (client, server) = MemTransport.CreatePair();
+ var currentAttempt = Interlocked.Increment(ref attempt);
+ if (currentAttempt == 1)
+ {
+ _ = Task.Run(() => FakeHost.New()
+ .OnInitialize((request, side, token) =>
+ RespondInitializeWithRootAsync(side, request.Id, null, 0, token))
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .On("subscribe", (request, side, token) =>
+ FakeHost.RespondResultAsync(side, request.Id, new SubscribeResult(), token))
+ .RunAsync(server, cts.Token));
+ }
+ else
+ {
+ _ = Task.Run(() => FakeHost.New()
+ .OnReconnect((request, side, token) =>
+ {
+ if (currentAttempt == 3)
+ {
+ secondReconnectParams.TrySetResult(
+ Ser.Deserialize(request.Params!.Value.GetRawText()));
+ }
+
+ var channels = new List
+ {
+ new ChannelRecovery(currentAttempt == 2
+ ? new ChannelSnapshotRecovery
+ {
+ Channel = channelA,
+ Snapshot = new Snapshot
+ {
+ Resource = channelA,
+ FromSeq = 50,
+ State = new SnapshotState(),
+ },
+ }
+ : (object)new ChannelReplayRecovery
+ {
+ Channel = channelA,
+ Actions = new List(),
+ }),
+ };
+ return FakeHost.RespondResultAsync(
+ side, request.Id, new ReconnectResult { Channels = channels }, token);
+ })
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .RunAsync(server, cts.Token));
+ }
+ return Task.FromResult(client);
+ };
+
+ var m = new MultiHostClient();
+ await using var _mh = m;
+ await m.AddHostAsync(new HostConfig
+ {
+ Id = new HostId("h"),
+ TransportFactory = factory,
+ ReconnectPolicy = new ReconnectPolicy
+ {
+ InitialBackoff = TimeSpan.FromMilliseconds(1),
+ MaxBackoff = TimeSpan.FromMilliseconds(1),
+ BackoffMultiplier = 1,
+ },
+ }, cts.Token);
+
+ await m.SubscribeAsync(new HostId("h"), channelA, cts.Token);
+
+ await m.ReconnectAsync(new HostId("h"), cts.Token);
+ await WaitUntilAsync(
+ () => m.Host(new HostId("h"))?.State.Kind == HostStateKind.Connected,
+ cts.Token);
+
+ await m.ReconnectAsync(new HostId("h"), cts.Token);
+ var observed = await secondReconnectParams.Task.WaitAsync(cts.Token);
+
+ Assert.Equal(
+ 50,
+ observed.Subscriptions.Single(s => s.Channel == channelA).LastSeenServerSeq);
+ }
+
+ [Fact]
+ public async Task MultiHost_ChannelMissingRecovery_DropsFromSubscriptionsAndIsNotReRequested()
+ {
+ const string channelA = "ahp-canvas:/a";
+ const string channelGone = "ahp-canvas:/gone";
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
+ var secondReconnectParams = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var attempt = 0;
+
+ HostTransportFactory factory = (id, ct) =>
+ {
+ var (client, server) = MemTransport.CreatePair();
+ var currentAttempt = Interlocked.Increment(ref attempt);
+ if (currentAttempt == 1)
+ {
+ _ = Task.Run(() => FakeHost.New()
+ .OnInitialize((request, side, token) =>
+ RespondInitializeWithRootAsync(side, request.Id, null, 0, token))
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .On("subscribe", (request, side, token) =>
+ FakeHost.RespondResultAsync(side, request.Id, new SubscribeResult(), token))
+ .RunAsync(server, cts.Token));
+ }
+ else
+ {
+ _ = Task.Run(() => FakeHost.New()
+ .OnReconnect((request, side, token) =>
+ {
+ if (currentAttempt == 3)
+ {
+ secondReconnectParams.TrySetResult(
+ Ser.Deserialize(request.Params!.Value.GetRawText()));
+ }
+
+ var channels = currentAttempt == 2
+ ? new List
+ {
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = channelA,
+ Actions = new List(),
+ }),
+ new ChannelRecovery(new ChannelMissingRecovery { Channel = channelGone }),
+ }
+ : new List
+ {
+ new ChannelRecovery(new ChannelReplayRecovery
+ {
+ Channel = channelA,
+ Actions = new List(),
+ }),
+ };
+ return FakeHost.RespondResultAsync(
+ side, request.Id, new ReconnectResult { Channels = channels }, token);
+ })
+ .OnListSessions((request, side, token) =>
+ RespondListSessionsAsync(side, request.Id, Array.Empty(), token))
+ .RunAsync(server, cts.Token));
+ }
+ return Task.FromResult(client);
+ };
+
+ var m = new MultiHostClient();
+ await using var _mh = m;
+ await m.AddHostAsync(new HostConfig
+ {
+ Id = new HostId("h"),
+ TransportFactory = factory,
+ ReconnectPolicy = new ReconnectPolicy
+ {
+ InitialBackoff = TimeSpan.FromMilliseconds(1),
+ MaxBackoff = TimeSpan.FromMilliseconds(1),
+ BackoffMultiplier = 1,
+ },
+ }, cts.Token);
+
+ await m.SubscribeAsync(new HostId("h"), channelA, cts.Token);
+ await m.SubscribeAsync(new HostId("h"), channelGone, cts.Token);
+
+ await m.ReconnectAsync(new HostId("h"), cts.Token);
+ await WaitUntilAsync(
+ () => !(m.Host(new HostId("h"))?.Subscriptions.Contains(channelGone) ?? true),
+ cts.Token);
+ Assert.Contains(channelA, m.Host(new HostId("h"))!.Subscriptions);
+
+ await m.ReconnectAsync(new HostId("h"), cts.Token);
+ var observed = await secondReconnectParams.Task.WaitAsync(cts.Token);
+
+ Assert.DoesNotContain(channelGone, observed.Subscriptions.Select(s => s.Channel));
+ }
}
diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs
index 06d74b5aa..e484f7153 100644
--- a/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs
+++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs
@@ -371,8 +371,9 @@ public async Task MultiHostReconnect_EmitsReconnectsWithOkOutcome()
})
.OnReconnect((req, side, c) =>
{
- var replay = new ReconnectReplayResult
+ var replay = new ChannelReplayRecovery
{
+ Channel = channel,
Actions = new List
{
new ActionEnvelope
@@ -386,9 +387,15 @@ public async Task MultiHostReconnect_EmitsReconnectsWithOkOutcome()
}),
},
},
- Missing = new List(),
};
- return FakeHost.RespondResultAsync(side, req.Id, new ReconnectResult(replay), c);
+ return FakeHost.RespondResultAsync(
+ side,
+ req.Id,
+ new ReconnectResult
+ {
+ Channels = new List { new ChannelRecovery(replay) },
+ },
+ c);
})
.RunAsync(s, ct));
}
diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs
index d0fa9c1d6..62a32f3e2 100644
--- a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs
+++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs
@@ -164,6 +164,10 @@ private static (object decoded, string reencoded) DecodeAndReencode(string type,
return Wrap(Ser.Deserialize(inputJson));
case "InitializeResult":
return Wrap(Ser.Deserialize(inputJson));
+ case "ReconnectParams":
+ return Wrap(Ser.Deserialize(inputJson));
+ case "ReconnectResult":
+ return Wrap(Ser.Deserialize(inputJson));
case "Snapshot":
return Wrap(Ser.Deserialize(inputJson));
default:
diff --git a/clients/go/ahp/client.go b/clients/go/ahp/client.go
index 1938bf369..532621245 100644
--- a/clients/go/ahp/client.go
+++ b/clients/go/ahp/client.go
@@ -779,12 +779,19 @@ func (c *Client) Initialize(ctx context.Context, clientID string, protocolVersio
// Reconnect re-establishes a dropped connection with the server's
// `reconnect` flow.
-func (c *Client) Reconnect(ctx context.Context, clientID string, lastSeenServerSeq int64, subscriptions []string) (*ahptypes.ReconnectResult, error) {
+//
+// subscriptions carries one [ahptypes.ChannelReplayCursor] per channel
+// the caller is still subscribed to, each with its own
+// LastSeenServerSeq checkpoint. The server recovers each channel
+// independently (see [ahptypes.ReconnectResult.Channels]) — callers
+// MUST NOT collapse these into a single connection-wide watermark, or
+// a fast-moving channel's checkpoint can silently cause a slower
+// channel's undelivered actions to be skipped on replay.
+func (c *Client) Reconnect(ctx context.Context, clientID string, subscriptions []ahptypes.ChannelReplayCursor) (*ahptypes.ReconnectResult, error) {
params := ahptypes.ReconnectParams{
- Channel: ahptypes.RootResourceURI,
- ClientId: clientID,
- LastSeenServerSeq: lastSeenServerSeq,
- Subscriptions: subscriptions,
+ Channel: ahptypes.RootResourceURI,
+ ClientId: clientID,
+ Subscriptions: subscriptions,
}
var out ahptypes.ReconnectResult
if err := c.Request(ctx, "reconnect", params, &out); err != nil {
diff --git a/clients/go/ahp/client_test.go b/clients/go/ahp/client_test.go
index 485adaf12..cd1829cd6 100644
--- a/clients/go/ahp/client_test.go
+++ b/clients/go/ahp/client_test.go
@@ -763,3 +763,189 @@ func TestShutdownIsIdempotent(t *testing.T) {
}
wg.Wait()
}
+
+// TestReconnectSendsIndependentPerChannelCursors drives a fake server
+// that asserts the `reconnect` request carries one independent
+// ChannelReplayCursor per subscribed channel rather than a single
+// connection-wide watermark. Channel A sits at serverSeq=100 while
+// channel B has raced ahead to serverSeq=101; a collapsed watermark
+// would report both at 101 (or some other merged value) and could
+// cause the server to skip A's still-undelivered actions on replay.
+func TestReconnectSendsIndependentPerChannelCursors(t *testing.T) {
+ const chanA = "ahp-session:/a"
+ const chanB = "ahp-session:/b"
+
+ clientSide, serverSide := newMemTransportPair()
+
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ msg, err := serverSide.Recv(ctx)
+ if err != nil {
+ t.Errorf("server recv: %v", err)
+ return
+ }
+ parsed, err := msg.IntoParsed()
+ if err != nil || parsed.Request == nil {
+ t.Errorf("expected request, got %+v (err=%v)", parsed, err)
+ return
+ }
+ if parsed.Request.Method != "reconnect" {
+ t.Errorf("method = %q, want %q", parsed.Request.Method, "reconnect")
+ }
+ var params ahptypes.ReconnectParams
+ if err := json.Unmarshal(parsed.Request.Params, ¶ms); err != nil {
+ t.Errorf("server decode params: %v", err)
+ return
+ }
+ cursors := map[ahptypes.URI]int64{}
+ for _, c := range params.Subscriptions {
+ cursors[c.Channel] = c.LastSeenServerSeq
+ }
+ if got, want := cursors[chanA], int64(100); got != want {
+ t.Errorf("channel A cursor = %d, want %d (must stay at its own baseline)", got, want)
+ }
+ if got, want := cursors[chanB], int64(101); got != want {
+ t.Errorf("channel B cursor = %d, want %d", got, want)
+ }
+
+ resultJSON := fmt.Sprintf(`{"channels": [
+ {"kind": "replay", "channel": %q, "actions": []},
+ {"kind": "replay", "channel": %q, "actions": []}
+ ]}`, chanA, chanB)
+ resp := ahptypes.JsonRpcMessage{SuccessResponse: &ahptypes.JsonRpcSuccessResponse{
+ JsonRpc: ahptypes.JsonRpcV2,
+ ID: parsed.Request.ID,
+ Result: json.RawMessage(resultJSON),
+ }}
+ out, err := EncodeMessage(resp)
+ if err != nil {
+ t.Errorf("server encode: %v", err)
+ return
+ }
+ if err := serverSide.Send(ctx, out); err != nil {
+ t.Errorf("server send: %v", err)
+ return
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ client, err := Connect(ctx, clientSide, DefaultConfig())
+ if err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ defer client.Shutdown(context.Background())
+
+ _, err = client.Reconnect(ctx, "test-client", []ahptypes.ChannelReplayCursor{
+ {Channel: chanA, LastSeenServerSeq: 100},
+ {Channel: chanB, LastSeenServerSeq: 101},
+ })
+ if err != nil {
+ t.Fatalf("Reconnect: %v", err)
+ }
+}
+
+// TestReconnectResultDecodesChannelRecoveryIndependently verifies that
+// a `reconnect` response carrying three different recovery kinds for
+// three different channels (replay / snapshot / missing, in the same
+// response) decodes each independently — one channel's recovery data
+// must never bleed into another's.
+func TestReconnectResultDecodesChannelRecoveryIndependently(t *testing.T) {
+ const chanA = "ahp-session:/a"
+ const chanB = "ahp-canvas:/b"
+ const chanC = "ahp-terminal:/c"
+
+ clientSide, serverSide := newMemTransportPair()
+
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ msg, err := serverSide.Recv(ctx)
+ if err != nil {
+ t.Errorf("server recv: %v", err)
+ return
+ }
+ parsed, err := msg.IntoParsed()
+ if err != nil || parsed.Request == nil {
+ t.Errorf("expected request, got %+v (err=%v)", parsed, err)
+ return
+ }
+
+ resultJSON := `{
+ "channels": [
+ {"kind": "replay", "channel": "ahp-session:/a", "actions": [
+ {"channel": "ahp-session:/a", "action": {"type": "session/titleChanged", "title": "Hello"}, "serverSeq": 11}
+ ]},
+ {"kind": "snapshot", "channel": "ahp-canvas:/b", "snapshot": {"resource": "ahp-canvas:/b", "state": {}, "fromSeq": 50}},
+ {"kind": "missing", "channel": "ahp-terminal:/c"}
+ ]
+ }`
+ resp := ahptypes.JsonRpcMessage{SuccessResponse: &ahptypes.JsonRpcSuccessResponse{
+ JsonRpc: ahptypes.JsonRpcV2,
+ ID: parsed.Request.ID,
+ Result: json.RawMessage(resultJSON),
+ }}
+ out, err := EncodeMessage(resp)
+ if err != nil {
+ t.Errorf("server encode: %v", err)
+ return
+ }
+ if err := serverSide.Send(ctx, out); err != nil {
+ t.Errorf("server send: %v", err)
+ return
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ client, err := Connect(ctx, clientSide, DefaultConfig())
+ if err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ defer client.Shutdown(context.Background())
+
+ result, err := client.Reconnect(ctx, "test-client", []ahptypes.ChannelReplayCursor{
+ {Channel: chanA, LastSeenServerSeq: 10},
+ {Channel: chanB, LastSeenServerSeq: 0},
+ {Channel: chanC, LastSeenServerSeq: 0},
+ })
+ if err != nil {
+ t.Fatalf("Reconnect: %v", err)
+ }
+ if len(result.Channels) != 3 {
+ t.Fatalf("len(Channels) = %d, want 3", len(result.Channels))
+ }
+
+ var sawReplayA, sawSnapshotB, sawMissingC bool
+ for _, recovery := range result.Channels {
+ switch v := recovery.Value.(type) {
+ case *ahptypes.ChannelReplayRecovery:
+ if v.Channel != chanA {
+ t.Errorf("replay recovery channel = %q, want %q", v.Channel, chanA)
+ }
+ if len(v.Actions) != 1 || v.Actions[0].ServerSeq != 11 {
+ t.Errorf("replay actions = %+v, want one action at serverSeq=11", v.Actions)
+ }
+ sawReplayA = true
+ case *ahptypes.ChannelSnapshotRecovery:
+ if v.Channel != chanB {
+ t.Errorf("snapshot recovery channel = %q, want %q", v.Channel, chanB)
+ }
+ if v.Snapshot.FromSeq != 50 {
+ t.Errorf("snapshot fromSeq = %d, want 50", v.Snapshot.FromSeq)
+ }
+ sawSnapshotB = true
+ case *ahptypes.ChannelMissingRecovery:
+ if v.Channel != chanC {
+ t.Errorf("missing recovery channel = %q, want %q", v.Channel, chanC)
+ }
+ sawMissingC = true
+ default:
+ t.Errorf("unexpected recovery variant: %T", v)
+ }
+ }
+ if !sawReplayA || !sawSnapshotB || !sawMissingC {
+ t.Errorf("missing expected recovery kinds: replayA=%v snapshotB=%v missingC=%v", sawReplayA, sawSnapshotB, sawMissingC)
+ }
+}
diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go
index cd3b2a6ad..4ccea6b1b 100644
--- a/clients/go/ahptypes/commands.generated.go
+++ b/clients/go/ahptypes/commands.generated.go
@@ -15,12 +15,16 @@ var _ = json.RawMessage(nil)
// ─── Enums ────────────────────────────────────────────────────────────
-// Discriminant for reconnect result types.
-type ReconnectResultType string
+// Discriminant for per-channel reconnect recovery outcomes.
+type ChannelRecoveryKind string
const (
- ReconnectResultTypeReplay ReconnectResultType = "replay"
- ReconnectResultTypeSnapshot ReconnectResultType = "snapshot"
+ // The server replayed the channel's missed actions.
+ ChannelRecoveryKindReplay ChannelRecoveryKind = "replay"
+ // The gap for this channel exceeded its replay buffer; a fresh snapshot is provided instead.
+ ChannelRecoveryKindSnapshot ChannelRecoveryKind = "snapshot"
+ // The channel can no longer be resumed (e.g. disposed, or no longer permitted).
+ ChannelRecoveryKindMissing ChannelRecoveryKind = "missing"
)
// How a new chat uses its source chat and turn.
@@ -297,8 +301,10 @@ type Implementation struct {
Title *string `json:"title,omitempty"`
}
-// Re-establishes a dropped connection. The server replays missed actions or
-// provides fresh snapshots.
+// Re-establishes a dropped connection. The server recovers each subscribed
+// channel independently — some channels may replay, others may receive a
+// fresh snapshot, and others may be reported missing, all in the same
+// response (see {@link ChannelRecovery}).
type ReconnectParams struct {
// Channel URI this command targets.
Channel URI `json:"channel"`
@@ -307,29 +313,79 @@ type ReconnectParams struct {
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
// Client identifier from the original connection
ClientId string `json:"clientId"`
- // Last `serverSeq` the client received
+ // Per-channel replay checkpoints for every channel the client is still subscribed to.
+ Subscriptions []ChannelReplayCursor `json:"subscriptions"`
+}
+
+// A single subscribed channel's replay checkpoint, carried in
+// `ReconnectParams.subscriptions`.
+//
+// Each subscription recovers independently from its own `lastSeenServerSeq`
+// instead of one connection-wide watermark. A single shared watermark lets a
+// fast-moving channel's `serverSeq` silently race ahead of a slower
+// channel's — if channel A has an undelivered action at `serverSeq=100` and
+// channel B goes on to deliver `serverSeq=101`, a connection-wide
+// `lastSeenServerSeq=101` would skip A's action entirely on replay. Tracking
+// one checkpoint per channel prevents that cross-channel skip without
+// claiming any ordering *between* channels.
+type ChannelReplayCursor struct {
+ // The subscribed channel URI.
+ Channel URI `json:"channel"`
+ // `serverSeq` of the last action the client fully applied or safely
+ // retained for `channel`, or the `fromSeq` of the `Snapshot` the client
+ // last used to initialize `channel` (see {@link Snapshot.fromSeq}).
+ //
+ // `0` means the client has no baseline for `channel` yet — e.g. it
+ // subscribed but the `subscribe`/`initialize` response snapshot (if any)
+ // never arrived before the connection dropped. The server MUST NOT use
+ // another channel's progress to advance this checkpoint, and MUST NOT
+ // use it to seed the connection's global `serverSeq` identity (see
+ // {@link InitializeResult.serverSeq}).
LastSeenServerSeq int64 `json:"lastSeenServerSeq"`
- // URIs the client was subscribed to
- Subscriptions []URI `json:"subscriptions"`
}
-// Reconnect result when the server can replay from the requested sequence.
+// Result of the `reconnect` command.
//
-// The server MUST include all replayed data in the response.
-type ReconnectReplayResult struct {
- // Missed action envelopes since `lastSeenServerSeq`
+// The server MUST include all replayed and snapshotted data in the response
+// before returning, and MUST include exactly one {@link ChannelRecovery} per
+// channel named in `ReconnectParams.subscriptions`.
+type ReconnectResult struct {
+ // One recovery outcome per requested subscription, in any order.
+ Channels []ChannelRecovery `json:"channels"`
+}
+
+// Recovery outcome for a channel that replayed cleanly.
+//
+// The server MUST include every action the channel missed since the
+// matching `ChannelReplayCursor.lastSeenServerSeq`, in ascending `serverSeq`
+// order, and MUST only include actions whose `ActionEnvelope.channel`
+// equals `channel`.
+type ChannelReplayRecovery struct {
+ // The channel this recovery applies to.
+ Channel URI `json:"channel"`
+ // Missed action envelopes since the requested `lastSeenServerSeq`.
Actions []ActionEnvelope `json:"actions"`
- // URIs from `ReconnectParams.subscriptions` that the server cannot resume.
- // This includes resources that no longer exist (e.g. disposed sessions or
- // terminals) as well as resources the client is no longer permitted to
- // observe. Clients SHOULD drop these from their local subscription set.
- Missing []URI `json:"missing"`
}
-// Reconnect result when the gap exceeds the replay buffer.
-type ReconnectSnapshotResult struct {
- // Fresh snapshots for each subscription
- Snapshots []Snapshot `json:"snapshots"`
+// Recovery outcome for a channel whose gap exceeded its replay buffer.
+//
+// Absent for stateless channels that have no state to snapshot; the server
+// MUST instead use {@link ChannelReplayRecovery} with an empty `actions`
+// list (or {@link ChannelMissingRecovery}, if the channel itself no longer
+// exists) for those.
+type ChannelSnapshotRecovery struct {
+ // The channel this recovery applies to.
+ Channel URI `json:"channel"`
+ // Fresh snapshot the client MUST use as its new baseline for this channel.
+ Snapshot Snapshot `json:"snapshot"`
+}
+
+// Recovery outcome for a channel the server cannot resume — e.g. a disposed
+// session or terminal, or a resource the client is no longer permitted to
+// observe. Clients SHOULD drop `channel` from their local subscription set.
+type ChannelMissingRecovery struct {
+ // The channel this recovery applies to.
+ Channel URI `json:"channel"`
}
// Subscribe to a URI-identified channel.
@@ -1636,54 +1692,77 @@ func (u ChatMoveDestination) MarshalJSON() ([]byte, error) {
return json.Marshal(u.Value)
}
-// ─── ReconnectResult Union ────────────────────────────────────────────
+// ─── ChannelRecovery Union ────────────────────────────────────────────
-// ReconnectResult is the result of the `reconnect` command.
-type ReconnectResult struct {
- Value isReconnectResult
+// ChannelRecovery is the per-channel reconnect recovery outcome.
+type ChannelRecovery struct {
+ Value isChannelRecovery
}
-// isReconnectResult is the marker interface implemented by every
-// concrete variant of ReconnectResult.
-type isReconnectResult interface{ isReconnectResult() }
+// isChannelRecovery is the marker interface implemented by every
+// concrete variant of ChannelRecovery.
+type isChannelRecovery interface{ isChannelRecovery() }
-func (*ReconnectReplayResult) isReconnectResult() {}
-func (*ReconnectSnapshotResult) isReconnectResult() {}
+func (*ChannelReplayRecovery) isChannelRecovery() {}
+func (*ChannelSnapshotRecovery) isChannelRecovery() {}
+func (*ChannelMissingRecovery) isChannelRecovery() {}
-// UnmarshalJSON decodes the variant indicated by the "type" discriminator.
-func (u *ReconnectResult) UnmarshalJSON(data []byte) error {
- disc, ok, err := readDiscriminator(data, "type")
+// UnmarshalJSON decodes the variant indicated by the "kind" discriminator.
+func (u *ChannelRecovery) UnmarshalJSON(data []byte) error {
+ disc, ok, err := readDiscriminator(data, "kind")
if err != nil {
return err
}
if !ok {
- return missingDiscriminatorError("ReconnectResult", "type")
+ return missingDiscriminatorError("ChannelRecovery", "kind")
}
switch disc {
case "replay":
- var value ReconnectReplayResult
+ var value ChannelReplayRecovery
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
case "snapshot":
- var value ReconnectSnapshotResult
+ var value ChannelSnapshotRecovery
+ if err := json.Unmarshal(data, &value); err != nil {
+ return err
+ }
+ u.Value = &value
+ case "missing":
+ var value ChannelMissingRecovery
if err := json.Unmarshal(data, &value); err != nil {
return err
}
u.Value = &value
default:
- return unknownDiscriminatorError("ReconnectResult", "type", disc)
+ return unknownDiscriminatorError("ChannelRecovery", "kind", disc)
}
return nil
}
// MarshalJSON encodes the active variant back to JSON.
-func (u ReconnectResult) MarshalJSON() ([]byte, error) {
+func (u ChannelRecovery) MarshalJSON() ([]byte, error) {
if u.Value == nil {
return []byte("null"), nil
}
- return json.Marshal(u.Value)
+ data, err := json.Marshal(u.Value)
+ if err != nil {
+ return nil, err
+ }
+ var object map[string]json.RawMessage
+ if err := json.Unmarshal(data, &object); err != nil {
+ return nil, err
+ }
+ switch u.Value.(type) {
+ case *ChannelReplayRecovery:
+ object["kind"] = json.RawMessage("\"replay\"")
+ case *ChannelSnapshotRecovery:
+ object["kind"] = json.RawMessage("\"snapshot\"")
+ case *ChannelMissingRecovery:
+ object["kind"] = json.RawMessage("\"missing\"")
+ }
+ return json.Marshal(object)
}
// ─── Changeset Operation Unions ───────────────────────────────────────
diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go
index 0f7181385..54edf2712 100644
--- a/clients/go/ahptypes/roundtrip_fixture_test.go
+++ b/clients/go/ahptypes/roundtrip_fixture_test.go
@@ -242,6 +242,14 @@ func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string {
var v InitializeResult
dec(&v)
return enc(&v)
+ case "ReconnectParams":
+ var v ReconnectParams
+ dec(&v)
+ return enc(&v)
+ case "ReconnectResult":
+ var v ReconnectResult
+ dec(&v)
+ return enc(&v)
case "ChatSource":
var v ChatSource
dec(&v)
diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt
index 48ed5f7b7..2be8753ef 100644
--- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt
+++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt
@@ -23,14 +23,25 @@ import kotlinx.serialization.json.contentOrNull
// ─── Command Enums ──────────────────────────────────────────────────────────
/**
- * Discriminant for reconnect result types.
+ * Discriminant for per-channel reconnect recovery outcomes.
*/
@Serializable
-enum class ReconnectResultType {
+enum class ChannelRecoveryKind {
+ /**
+ * The server replayed the channel's missed actions.
+ */
@SerialName("replay")
REPLAY,
+ /**
+ * The gap for this channel exceeded its replay buffer; a fresh snapshot is provided instead.
+ */
@SerialName("snapshot")
- SNAPSHOT
+ SNAPSHOT,
+ /**
+ * The channel can no longer be resumed (e.g. disposed, or no longer permitted).
+ */
+ @SerialName("missing")
+ MISSING
}
/**
@@ -520,44 +531,82 @@ data class ReconnectParams(
*/
val clientId: String,
/**
- * Last `serverSeq` the client received
+ * Per-channel replay checkpoints for every channel the client is still subscribed to.
*/
- val lastSeenServerSeq: Long,
+ val subscriptions: List
+)
+
+@Serializable
+data class ChannelReplayCursor(
/**
- * URIs the client was subscribed to
+ * The subscribed channel URI.
*/
- val subscriptions: List
+ val channel: String,
+ /**
+ * `serverSeq` of the last action the client fully applied or safely
+ * retained for `channel`, or the `fromSeq` of the `Snapshot` the client
+ * last used to initialize `channel` (see {@link Snapshot.fromSeq}).
+ *
+ * `0` means the client has no baseline for `channel` yet — e.g. it
+ * subscribed but the `subscribe`/`initialize` response snapshot (if any)
+ * never arrived before the connection dropped. The server MUST NOT use
+ * another channel's progress to advance this checkpoint, and MUST NOT
+ * use it to seed the connection's global `serverSeq` identity (see
+ * {@link InitializeResult.serverSeq}).
+ */
+ val lastSeenServerSeq: Long
)
@Serializable
-data class ReconnectReplayResult(
+data class ReconnectResult(
+ /**
+ * One recovery outcome per requested subscription, in any order.
+ */
+ val channels: List
+)
+
+@Serializable
+data class ChannelReplayRecovery(
+ /**
+ * Discriminant
+ */
+ val kind: ChannelRecoveryKind,
+ /**
+ * The channel this recovery applies to.
+ */
+ val channel: String,
+ /**
+ * Missed action envelopes since the requested `lastSeenServerSeq`.
+ */
+ val actions: List
+)
+
+@Serializable
+data class ChannelSnapshotRecovery(
/**
* Discriminant
*/
- val type: ReconnectResultType,
+ val kind: ChannelRecoveryKind,
/**
- * Missed action envelopes since `lastSeenServerSeq`
+ * The channel this recovery applies to.
*/
- val actions: List,
+ val channel: String,
/**
- * URIs from `ReconnectParams.subscriptions` that the server cannot resume.
- * This includes resources that no longer exist (e.g. disposed sessions or
- * terminals) as well as resources the client is no longer permitted to
- * observe. Clients SHOULD drop these from their local subscription set.
+ * Fresh snapshot the client MUST use as its new baseline for this channel.
*/
- val missing: List
+ val snapshot: Snapshot
)
@Serializable
-data class ReconnectSnapshotResult(
+data class ChannelMissingRecovery(
/**
* Discriminant
*/
- val type: ReconnectResultType,
+ val kind: ChannelRecoveryKind,
/**
- * Fresh snapshots for each subscription
+ * The channel this recovery applies to.
*/
- val snapshots: List
+ val channel: String
)
@Serializable
@@ -1873,41 +1922,45 @@ internal object ChatMoveDestinationSerializer : KSerializer
}
}
-// ─── ReconnectResult Union ──────────────────────────────────────────────────
+// ─── ChannelRecovery Union ──────────────────────────────────────────────────
-@Serializable(with = ReconnectResultSerializer::class)
-sealed interface ReconnectResult
+@Serializable(with = ChannelRecoverySerializer::class)
+sealed interface ChannelRecovery
@JvmInline
-value class ReconnectResultReplay(val value: ReconnectReplayResult) : ReconnectResult
+value class ChannelRecoveryReplay(val value: ChannelReplayRecovery) : ChannelRecovery
@JvmInline
-value class ReconnectResultSnapshot(val value: ReconnectSnapshotResult) : ReconnectResult
+value class ChannelRecoverySnapshot(val value: ChannelSnapshotRecovery) : ChannelRecovery
+@JvmInline
+value class ChannelRecoveryMissing(val value: ChannelMissingRecovery) : ChannelRecovery
-internal object ReconnectResultSerializer : KSerializer {
+internal object ChannelRecoverySerializer : KSerializer {
override val descriptor: SerialDescriptor =
- buildClassSerialDescriptor("ReconnectResult")
+ buildClassSerialDescriptor("ChannelRecovery")
- override fun deserialize(decoder: Decoder): ReconnectResult {
+ override fun deserialize(decoder: Decoder): ChannelRecovery {
val input = decoder as? JsonDecoder
- ?: error("ReconnectResult can only be deserialized from JSON")
+ ?: error("ChannelRecovery can only be deserialized from JSON")
val element = input.decodeJsonElement()
val obj = element as? JsonObject
- ?: error("Expected JsonObject for ReconnectResult")
- val discriminant = (obj["type"] as? JsonPrimitive)?.content
- ?: error("Missing type discriminator on ReconnectResult")
+ ?: error("Expected JsonObject for ChannelRecovery")
+ val discriminant = (obj["kind"] as? JsonPrimitive)?.content
+ ?: error("Missing kind discriminator on ChannelRecovery")
return when (discriminant) {
- "replay" -> ReconnectResultReplay(input.json.decodeFromJsonElement(ReconnectReplayResult.serializer(), element))
- "snapshot" -> ReconnectResultSnapshot(input.json.decodeFromJsonElement(ReconnectSnapshotResult.serializer(), element))
- else -> error("Unknown ReconnectResult discriminator: $discriminant")
+ "replay" -> ChannelRecoveryReplay(input.json.decodeFromJsonElement(ChannelReplayRecovery.serializer(), element))
+ "snapshot" -> ChannelRecoverySnapshot(input.json.decodeFromJsonElement(ChannelSnapshotRecovery.serializer(), element))
+ "missing" -> ChannelRecoveryMissing(input.json.decodeFromJsonElement(ChannelMissingRecovery.serializer(), element))
+ else -> error("Unknown ChannelRecovery discriminator: $discriminant")
}
}
- override fun serialize(encoder: Encoder, value: ReconnectResult) {
+ override fun serialize(encoder: Encoder, value: ChannelRecovery) {
val output = encoder as? JsonEncoder
- ?: error("ReconnectResult can only be serialized to JSON")
+ ?: error("ChannelRecovery can only be serialized to JSON")
val element: JsonElement = when (value) {
- is ReconnectResultReplay -> output.json.encodeToJsonElement(ReconnectReplayResult.serializer(), value.value)
- is ReconnectResultSnapshot -> output.json.encodeToJsonElement(ReconnectSnapshotResult.serializer(), value.value)
+ is ChannelRecoveryReplay -> output.json.encodeToJsonElement(ChannelReplayRecovery.serializer(), value.value)
+ is ChannelRecoverySnapshot -> output.json.encodeToJsonElement(ChannelSnapshotRecovery.serializer(), value.value)
+ is ChannelRecoveryMissing -> output.json.encodeToJsonElement(ChannelMissingRecovery.serializer(), value.value)
}
output.encodeJsonElement(element)
}
diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt
index 7af1319f9..21c0459b1 100644
--- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt
+++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt
@@ -39,6 +39,8 @@ import com.microsoft.agenthostprotocol.generated.JsonRpcNotification
import com.microsoft.agenthostprotocol.generated.JsonRpcRequest
import com.microsoft.agenthostprotocol.generated.JsonRpcSuccessResponse
import com.microsoft.agenthostprotocol.generated.PartialSessionSummary
+import com.microsoft.agenthostprotocol.generated.ReconnectParams
+import com.microsoft.agenthostprotocol.generated.ReconnectResult
import com.microsoft.agenthostprotocol.generated.SessionAddedParams
import com.microsoft.agenthostprotocol.generated.ChatInputQuestion
import com.microsoft.agenthostprotocol.generated.SessionStatus
@@ -253,6 +255,8 @@ class RoundTripCorpusTest {
"PartialSessionSummary" -> rt(PartialSessionSummary.serializer())
"Implementation" -> rt(Implementation.serializer())
"InitializeResult" -> rt(InitializeResult.serializer())
+ "ReconnectParams" -> rt(ReconnectParams.serializer())
+ "ReconnectResult" -> rt(ReconnectResult.serializer())
"ChatSource" -> rt(ChatSource.serializer())
"Snapshot" -> rt(Snapshot.serializer())
else -> fail(
diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs
index 37e8955b5..6cf6e691c 100644
--- a/clients/rust/crates/ahp-types/src/commands.rs
+++ b/clients/rust/crates/ahp-types/src/commands.rs
@@ -23,13 +23,18 @@ use crate::state::{
// ─── Enums ────────────────────────────────────────────────────────────
-/// Discriminant for reconnect result types.
+/// Discriminant for per-channel reconnect recovery outcomes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub enum ReconnectResultType {
+pub enum ChannelRecoveryKind {
+ /// The server replayed the channel's missed actions.
#[serde(rename = "replay")]
Replay,
+ /// The gap for this channel exceeded its replay buffer; a fresh snapshot is provided instead.
#[serde(rename = "snapshot")]
Snapshot,
+ /// The channel can no longer be resumed (e.g. disposed, or no longer permitted).
+ #[serde(rename = "missing")]
+ Missing,
}
/// How a new chat uses its source chat and turn.
@@ -457,8 +462,10 @@ pub struct Implementation {
pub title: Option,
}
-/// Re-establishes a dropped connection. The server replays missed actions or
-/// provides fresh snapshots.
+/// Re-establishes a dropped connection. The server recovers each subscribed
+/// channel independently — some channels may replay, others may receive a
+/// fresh snapshot, and others may be reported missing, all in the same
+/// response (see {@link ChannelRecovery}).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReconnectParams {
@@ -470,33 +477,89 @@ pub struct ReconnectParams {
pub meta: Option,
/// Client identifier from the original connection
pub client_id: String,
- /// Last `serverSeq` the client received
+ /// Per-channel replay checkpoints for every channel the client is still subscribed to.
+ pub subscriptions: Vec,
+}
+
+/// A single subscribed channel's replay checkpoint, carried in
+/// `ReconnectParams.subscriptions`.
+///
+/// Each subscription recovers independently from its own `lastSeenServerSeq`
+/// instead of one connection-wide watermark. A single shared watermark lets a
+/// fast-moving channel's `serverSeq` silently race ahead of a slower
+/// channel's — if channel A has an undelivered action at `serverSeq=100` and
+/// channel B goes on to deliver `serverSeq=101`, a connection-wide
+/// `lastSeenServerSeq=101` would skip A's action entirely on replay. Tracking
+/// one checkpoint per channel prevents that cross-channel skip without
+/// claiming any ordering *between* channels.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ChannelReplayCursor {
+ /// The subscribed channel URI.
+ pub channel: Uri,
+ /// `serverSeq` of the last action the client fully applied or safely
+ /// retained for `channel`, or the `fromSeq` of the `Snapshot` the client
+ /// last used to initialize `channel` (see {@link Snapshot.fromSeq}).
+ ///
+ /// `0` means the client has no baseline for `channel` yet — e.g. it
+ /// subscribed but the `subscribe`/`initialize` response snapshot (if any)
+ /// never arrived before the connection dropped. The server MUST NOT use
+ /// another channel's progress to advance this checkpoint, and MUST NOT
+ /// use it to seed the connection's global `serverSeq` identity (see
+ /// {@link InitializeResult.serverSeq}).
pub last_seen_server_seq: i64,
- /// URIs the client was subscribed to
- pub subscriptions: Vec,
}
-/// Reconnect result when the server can replay from the requested sequence.
+/// Result of the `reconnect` command.
+///
+/// The server MUST include all replayed and snapshotted data in the response
+/// before returning, and MUST include exactly one {@link ChannelRecovery} per
+/// channel named in `ReconnectParams.subscriptions`.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReconnectResult {
+ /// One recovery outcome per requested subscription, in any order.
+ pub channels: Vec,
+}
+
+/// Recovery outcome for a channel that replayed cleanly.
///
-/// The server MUST include all replayed data in the response.
+/// The server MUST include every action the channel missed since the
+/// matching `ChannelReplayCursor.lastSeenServerSeq`, in ascending `serverSeq`
+/// order, and MUST only include actions whose `ActionEnvelope.channel`
+/// equals `channel`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
-pub struct ReconnectReplayResult {
- /// Missed action envelopes since `lastSeenServerSeq`
+pub struct ChannelReplayRecovery {
+ /// The channel this recovery applies to.
+ pub channel: Uri,
+ /// Missed action envelopes since the requested `lastSeenServerSeq`.
pub actions: Vec,
- /// URIs from `ReconnectParams.subscriptions` that the server cannot resume.
- /// This includes resources that no longer exist (e.g. disposed sessions or
- /// terminals) as well as resources the client is no longer permitted to
- /// observe. Clients SHOULD drop these from their local subscription set.
- pub missing: Vec,
}
-/// Reconnect result when the gap exceeds the replay buffer.
+/// Recovery outcome for a channel whose gap exceeded its replay buffer.
+///
+/// Absent for stateless channels that have no state to snapshot; the server
+/// MUST instead use {@link ChannelReplayRecovery} with an empty `actions`
+/// list (or {@link ChannelMissingRecovery}, if the channel itself no longer
+/// exists) for those.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
-pub struct ReconnectSnapshotResult {
- /// Fresh snapshots for each subscription
- pub snapshots: Vec,
+pub struct ChannelSnapshotRecovery {
+ /// The channel this recovery applies to.
+ pub channel: Uri,
+ /// Fresh snapshot the client MUST use as its new baseline for this channel.
+ pub snapshot: Snapshot,
+}
+
+/// Recovery outcome for a channel the server cannot resume — e.g. a disposed
+/// session or terminal, or a resource the client is no longer permitted to
+/// observe. Clients SHOULD drop `channel` from their local subscription set.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ChannelMissingRecovery {
+ /// The channel this recovery applies to.
+ pub channel: Uri,
}
/// Subscribe to a URI-identified channel.
@@ -1829,16 +1892,18 @@ pub enum ChatMoveDestination {
Unknown(serde_json::Value),
}
-// ─── ReconnectResult Union ────────────────────────────────────────────
+// ─── ChannelRecovery Union ────────────────────────────────────────────
-/// Result of the `reconnect` command.
+/// Per-channel reconnect recovery outcome.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-#[serde(tag = "type")]
-pub enum ReconnectResult {
+#[serde(tag = "kind")]
+pub enum ChannelRecovery {
#[serde(rename = "replay")]
- Replay(ReconnectReplayResult),
+ Replay(ChannelReplayRecovery),
#[serde(rename = "snapshot")]
- Snapshot(ReconnectSnapshotResult),
+ Snapshot(ChannelSnapshotRecovery),
+ #[serde(rename = "missing")]
+ Missing(ChannelMissingRecovery),
}
// ─── Changeset Operation Unions ───────────────────────────────────────
diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs
index 1b81a9caf..a6fef1c1f 100644
--- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs
+++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs
@@ -28,7 +28,10 @@
use ahp_types::{
actions::{ActionEnvelope, StateAction},
- commands::{ChangesetOperationTarget, ChatSource, Implementation, InitializeResult},
+ commands::{
+ ChangesetOperationTarget, ChatSource, Implementation, InitializeResult, ReconnectParams,
+ ReconnectResult,
+ },
common::StringOrMarkdown,
messages::JsonRpcMessage,
notifications::{PartialSessionSummary, SessionAddedParams},
@@ -223,6 +226,8 @@ fn decode_and_reencode(file: &str, type_name: &str, input_json: &str) -> Result<
"PartialSessionSummary" => round_trip!(PartialSessionSummary),
"Implementation" => round_trip!(Implementation),
"InitializeResult" => round_trip!(InitializeResult),
+ "ReconnectParams" => round_trip!(ReconnectParams),
+ "ReconnectResult" => round_trip!(ReconnectResult),
"ChatSource" => round_trip!(ChatSource),
"Snapshot" => round_trip!(Snapshot),
other => Err(format!(
diff --git a/clients/rust/crates/ahp/src/client.rs b/clients/rust/crates/ahp/src/client.rs
index 4b9e5f05e..5bbfe4c22 100644
--- a/clients/rust/crates/ahp/src/client.rs
+++ b/clients/rust/crates/ahp/src/client.rs
@@ -32,9 +32,10 @@ use std::time::Duration;
use ahp_types::actions::{ActionEnvelope, StateAction};
use ahp_types::commands::{
- CompletionsParams, CompletionsResult, CreateResourceWatchParams, CreateResourceWatchResult,
- DispatchActionParams, InitializeParams, InitializeResult, ReconnectParams, ReconnectResult,
- ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult,
+ ChannelReplayCursor, CompletionsParams, CompletionsResult, CreateResourceWatchParams,
+ CreateResourceWatchResult, DispatchActionParams, InitializeParams, InitializeResult,
+ ReconnectParams, ReconnectResult, ResourceCopyParams, ResourceCopyResult,
+ ResourceDeleteParams, ResourceDeleteResult,
ResourceListParams, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult,
ResourceMoveParams, ResourceMoveResult, ResourceReadParams, ResourceReadResult,
ResourceRequestParams, ResourceRequestResult, ResourceResolveParams, ResourceResolveResult,
@@ -514,17 +515,21 @@ impl Client {
}
/// Re-establish a dropped connection with `reconnect`.
+ ///
+ /// `subscriptions` carries one [`ChannelReplayCursor`] per channel the
+ /// client is still subscribed to, each independently recording the
+ /// highest `serverSeq` that channel has fully applied. Unlike a single
+ /// connection-wide watermark, this means a fast-moving channel can never
+ /// cause a slower channel's undelivered actions to be silently skipped.
pub async fn reconnect(
&self,
client_id: String,
- last_seen_server_seq: i64,
- subscriptions: Vec,
+ subscriptions: Vec,
) -> Result {
let params = ReconnectParams {
channel: ROOT_RESOURCE_URI.to_string(),
meta: None,
client_id,
- last_seen_server_seq,
subscriptions,
};
self.request("reconnect", params).await
diff --git a/clients/rust/crates/ahp/src/hosts/runtime.rs b/clients/rust/crates/ahp/src/hosts/runtime.rs
index 976d0399a..17fcf3e79 100644
--- a/clients/rust/crates/ahp/src/hosts/runtime.rs
+++ b/clients/rust/crates/ahp/src/hosts/runtime.rs
@@ -11,7 +11,8 @@ use std::time::SystemTime;
use ahp_types::actions::{ActionEnvelope, StateAction};
use ahp_types::commands::{
- ListSessionsParams, ListSessionsResult, ReconnectResult, SubscribeParams, SubscribeResult,
+ ChannelRecovery, ChannelReplayCursor, ListSessionsParams, ListSessionsResult, ReconnectResult,
+ SubscribeParams, SubscribeResult,
};
use ahp_types::common::{Uri, ROOT_RESOURCE_URI};
use ahp_types::state::{RootState, SessionSummary, SnapshotState};
@@ -92,6 +93,7 @@ pub(super) fn spawn(
last_connected_at: None,
protocol_version: None,
server_seq: 0,
+ channel_cursors: std::collections::HashMap::new(),
default_directory: None,
automations: None,
root_state: RootState {
@@ -269,18 +271,28 @@ impl HostRuntime {
let events = client.events();
// Decide between initialize and reconnect based on prior state.
- let (subscriptions, server_seq_after, init_result, reconnect_result) = {
+ let (_subscriptions, server_seq_after, init_result, reconnect_result) = {
let snapshot = self.shared.lock().await;
- let can_reconnect = snapshot.server_seq > 0 && !snapshot.subscriptions.is_empty();
+ let can_reconnect =
+ snapshot.protocol_version.is_some() && !snapshot.subscriptions.is_empty();
let subscriptions = snapshot.subscriptions.clone();
let server_seq = snapshot.server_seq;
+ // Build one independent replay cursor per currently-subscribed
+ // channel from the per-channel cursor map (missing = 0). A
+ // single connection-wide watermark would let a fast channel's
+ // progress silently skip a slower channel's undelivered
+ // actions; per-channel cursors prevent that cross-channel skip.
+ let cursors: Vec = subscriptions
+ .iter()
+ .map(|uri| ChannelReplayCursor {
+ channel: uri.clone(),
+ last_seen_server_seq: snapshot.channel_cursors.get(uri).copied().unwrap_or(0),
+ })
+ .collect();
drop(snapshot);
if can_reconnect {
- match client
- .reconnect(self.client_id.clone(), server_seq, subscriptions.clone())
- .await
- {
+ match client.reconnect(self.client_id.clone(), cursors).await {
Ok(result) => (subscriptions, server_seq, None, Some(result)),
Err(ClientError::Rpc(_)) => {
// Server refused reconnect (likely too much state has
@@ -352,6 +364,17 @@ impl HostRuntime {
.completion_trigger_characters
.clone()
.unwrap_or_default();
+ // A full (re)initialize discards any prior per-channel
+ // progress — the server has no continuity guarantee for
+ // it — and re-baselines every subscribed channel from its
+ // fresh snapshot (or leaves it unset if the channel is
+ // stateless).
+ state.channel_cursors.clear();
+ for snapshot in &init.snapshots {
+ state
+ .channel_cursors
+ .insert(snapshot.resource.clone(), snapshot.from_seq);
+ }
}
if let Ok(list) = summaries {
state.session_summaries.clear();
@@ -370,7 +393,7 @@ impl HostRuntime {
// mirrors and aggregated views stay correct; missing
// subscriptions must be dropped from the cache.
if let Some(result) = reconnect_result {
- self.apply_reconnect_result(result, &subscriptions).await;
+ self.apply_reconnect_result(result).await;
}
self.set_state(HostState::Connected, None).await;
@@ -391,58 +414,66 @@ impl HostRuntime {
/// Apply the result of a `reconnect` call.
///
- /// For [`ReconnectResult::Replay`]: fans the missed action envelopes
- /// through the per-host event tap and the per-host state mirror so
- /// consumers see them in `serverSeq` order, then drops any
- /// `missing` URIs from the local subscription set.
+ /// The server recovers each subscribed channel independently — some
+ /// channels may replay, others may receive a fresh snapshot, and
+ /// others may be reported missing, all in the same response. Each
+ /// requested channel gets exactly one recovery outcome, so a fast
+ /// channel's progress can't cause a slower channel's undelivered
+ /// actions to be skipped (or a missing channel to hide another
+ /// channel's replay):
///
- /// For [`ReconnectResult::Snapshot`]: refreshes the per-host root
- /// state mirror and records the snapshot's `from_seq` in the
- /// supervisor's `serverSeq`. URIs the server didn't return a
- /// snapshot for are dropped from the local subscription set.
- async fn apply_reconnect_result(
- &self,
- result: ReconnectResult,
- prior_subscriptions: &[String],
- ) {
- match result {
- ReconnectResult::Replay(replay) => {
- for envelope in replay.actions {
- let channel = envelope.channel.clone();
- self.apply_action(&envelope).await;
- let host_event = HostSubscriptionEvent {
- host_id: self.config.id.clone(),
- channel: channel.clone(),
- event: SubscriptionEvent::Action(envelope),
- };
- let _ = self.fan_out.send(host_event);
+ /// - [`ChannelRecovery::Replay`]: fans the missed action envelopes
+ /// through the per-host event tap and the per-host state mirror
+ /// (via [`Self::apply_action`], which advances only the matching
+ /// channel's cursor) so consumers see them in `serverSeq` order.
+ /// - [`ChannelRecovery::Snapshot`]: refreshes the per-host root
+ /// state mirror (if the channel is root) and sets that channel's
+ /// cursor to the snapshot's `from_seq` baseline.
+ /// - [`ChannelRecovery::Missing`]: drops the channel from the local
+ /// subscription set and its cursor entry, so it is never
+ /// re-requested on a future reconnect.
+ async fn apply_reconnect_result(&self, result: ReconnectResult) {
+ let mut missing_channels: Vec = Vec::new();
+ for recovery in result.channels {
+ match recovery {
+ ChannelRecovery::Replay(replay) => {
+ for envelope in replay.actions {
+ let channel = envelope.channel.clone();
+ self.apply_action(&envelope).await;
+ let host_event = HostSubscriptionEvent {
+ host_id: self.config.id.clone(),
+ channel: channel.clone(),
+ event: SubscriptionEvent::Action(envelope),
+ };
+ let _ = self.fan_out.send(host_event);
+ }
}
- if !replay.missing.is_empty() {
+ ChannelRecovery::Snapshot(snap) => {
let mut state = self.shared.lock().await;
- state.subscriptions.retain(|u| !replay.missing.contains(u));
- }
- let _ = prior_subscriptions; // intentionally unused on replay
- }
- ReconnectResult::Snapshot(snap) => {
- let mut state = self.shared.lock().await;
- let mut surviving: Vec = Vec::with_capacity(snap.snapshots.len());
- for snapshot in snap.snapshots {
- if snapshot.from_seq > state.server_seq {
- state.server_seq = snapshot.from_seq;
+ if snap.snapshot.from_seq > state.server_seq {
+ state.server_seq = snap.snapshot.from_seq;
}
- if snapshot.resource == ahp_types::ROOT_RESOURCE_URI {
- if let SnapshotState::Root(root) = &snapshot.state {
+ state
+ .channel_cursors
+ .insert(snap.channel.clone(), snap.snapshot.from_seq);
+ if snap.channel == ahp_types::ROOT_RESOURCE_URI {
+ if let SnapshotState::Root(root) = &snap.snapshot.state {
state.root_state = root.as_ref().clone();
}
}
- surviving.push(snapshot.resource);
}
- // Drop subscriptions the server didn't return a snapshot
- // for — they're effectively `missing` even though the
- // snapshot arm doesn't carry an explicit list.
- state
- .subscriptions
- .retain(|u| surviving.contains(u) || !prior_subscriptions.contains(u));
+ ChannelRecovery::Missing(missing) => {
+ missing_channels.push(missing.channel);
+ }
+ }
+ }
+ if !missing_channels.is_empty() {
+ let mut state = self.shared.lock().await;
+ state
+ .subscriptions
+ .retain(|u| !missing_channels.contains(u));
+ for channel in &missing_channels {
+ state.channel_cursors.remove(channel);
}
}
}
@@ -504,6 +535,7 @@ impl HostRuntime {
{
let mut state = self.shared.lock().await;
state.subscriptions.retain(|u| u != &uri);
+ state.channel_cursors.remove(&uri);
}
let _ = reply.send(Ok(()));
}
@@ -546,6 +578,7 @@ impl HostRuntime {
{
let mut state = self.shared.lock().await;
state.subscriptions.retain(|u| u != &uri);
+ state.channel_cursors.remove(&uri);
}
let _ = reply.send(Ok(()));
}
@@ -599,6 +632,20 @@ impl HostRuntime {
if envelope_seq > state.server_seq {
state.server_seq = envelope_seq;
}
+ // Advance only the matching channel's cursor — never another
+ // channel's — so a fast channel can never cause a slower
+ // channel's undelivered actions to be skipped on the next
+ // reconnect.
+ let prior_cursor = state
+ .channel_cursors
+ .get(&envelope.channel)
+ .copied()
+ .unwrap_or(0);
+ if envelope_seq > prior_cursor {
+ state
+ .channel_cursors
+ .insert(envelope.channel.clone(), envelope_seq);
+ }
// Best-effort root state mirror update; for non-root actions this
// is a no-op (the reducer reports OutOfScope).
if matches!(
@@ -633,12 +680,24 @@ impl HostRuntime {
)
.await
.map_err(HostError::Client)?;
- // Track subscription so reconnect can replay it.
+ // Track subscription so reconnect can replay it. Seed (or raise)
+ // the channel's replay cursor from the subscribe response's
+ // snapshot `from_seq` baseline — never lower an existing cursor,
+ // since a re-subscribe shouldn't rewind a channel that's already
+ // caught up further via reconnect/replay.
{
let mut state = self.shared.lock().await;
if !state.subscriptions.contains(&uri) {
state.subscriptions.push(uri.clone());
}
+ if let Some(baseline) = result.snapshot.as_ref().map(|s| s.from_seq) {
+ let current = state.channel_cursors.get(&uri).copied();
+ if current.map(|c| baseline > c).unwrap_or(true) {
+ state.channel_cursors.insert(uri.clone(), baseline);
+ }
+ } else {
+ state.channel_cursors.entry(uri.clone()).or_insert(0);
+ }
}
// Make sure local broadcasts exist so per-URI listeners don't miss events.
let _ = client.attach_subscription(&uri).await;
@@ -655,6 +714,7 @@ impl HostRuntime {
}
let mut state = self.shared.lock().await;
state.subscriptions.retain(|u| u != &uri);
+ state.channel_cursors.remove(&uri);
Ok(())
}
diff --git a/clients/rust/crates/ahp/src/hosts/types.rs b/clients/rust/crates/ahp/src/hosts/types.rs
index ec47aaa12..62dbfcebf 100644
--- a/clients/rust/crates/ahp/src/hosts/types.rs
+++ b/clients/rust/crates/ahp/src/hosts/types.rs
@@ -233,7 +233,10 @@ pub struct HostHandle {
/// Protocol version negotiated with the host on the most recent
/// successful `initialize`.
pub protocol_version: Option,
- /// Highest `serverSeq` observed on this host.
+ /// Highest `serverSeq` ever observed across any channel. Informational
+ /// only — reconnect recovery for each subscribed channel is driven by
+ /// its own independent replay cursor, so this value is never used to
+ /// decide what gets replayed.
pub server_seq: i64,
/// Optional `defaultDirectory` from the host's `InitializeResult`.
pub default_directory: Option,
@@ -545,7 +548,19 @@ pub(super) struct HostInternal {
pub(super) last_error: Option>,
pub(super) last_connected_at: Option,
pub(super) protocol_version: Option,
+ /// Highest `serverSeq` ever observed across any channel. Informational
+ /// only (surfaced on [`HostHandle`] for diagnostics) — it is never used
+ /// to decide what to replay on reconnect. Per-channel recovery uses
+ /// [`channel_cursors`](Self::channel_cursors) instead, so a fast
+ /// channel's `serverSeq` can never cause a slower channel's undelivered
+ /// actions to be skipped.
pub(super) server_seq: i64,
+ /// Per-channel replay cursors: the highest `serverSeq` fully applied
+ /// (or snapshot baseline established) for each currently-subscribed
+ /// channel. Keyed by channel URI. Drives the `subscriptions` sent on
+ /// [`Client::reconnect`] and is only ever advanced by envelopes or
+ /// snapshots for the matching channel — never cross-channel.
+ pub(super) channel_cursors: std::collections::HashMap,
pub(super) default_directory: Option,
pub(super) automations: Option,
pub(super) root_state: RootState,
@@ -584,7 +599,7 @@ impl HostInternal {
/// [`ActionEnvelope`] in one place. The protocol exposes `serverSeq`
/// as a non-negative wire counter; the SDK holds it as `i64` to match
/// the rest of the surface (`InitializeResult::server_seq`,
-/// `ReconnectParams::last_seen_server_seq`).
+/// `ChannelReplayCursor::last_seen_server_seq`).
pub(super) fn server_seq_from_envelope(env: &ActionEnvelope) -> i64 {
env.server_seq as i64
}
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 fa1481b53..2f4420acc 100644
--- a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs
+++ b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs
@@ -27,7 +27,7 @@
//! a missed-because-reconnected envelope) will permanently desync the
//! mirror for that `(host, channel)` until you re-seed it from a fresh
//! snapshot — either via a new `subscribe` call or by applying a
-//! `Snapshot` from `ReconnectResult::Snapshot` through
+//! `Snapshot` from `ChannelRecovery::Snapshot` through
//! [`MultiHostStateMirror::apply_snapshot`].
//!
//! Consume from this mirror with that understanding: it's the right
diff --git a/clients/rust/crates/ahp/tests/hosts.rs b/clients/rust/crates/ahp/tests/hosts.rs
index 88399c7b8..1fce67af8 100644
--- a/clients/rust/crates/ahp/tests/hosts.rs
+++ b/clients/rust/crates/ahp/tests/hosts.rs
@@ -54,6 +54,12 @@ impl Transport for MemTransport {
// ─── Fake host ──────────────────────────────────────────────────────────────
+/// Pluggable per-channel-cursor reconnect responder, overriding the
+/// default empty-replay-per-channel behaviour. Mirrors the TypeScript
+/// harness's `handleReconnect` callback.
+type ReconnectResponder =
+ Arc ahp_types::commands::ReconnectResult + Send + Sync>;
+
#[derive(Clone)]
struct FakeHostState {
/// Sequential serverSeq counter shared across reconnects on this host.
@@ -64,6 +70,16 @@ struct FakeHostState {
sessions: Vec,
/// Automation support to advertise in `InitializeResult`.
automations: Option,
+ /// Optional override for the `reconnect` response; see
+ /// [`ReconnectResponder`]. Defaults to an empty `Replay` per
+ /// requested channel when unset.
+ handle_reconnect: Option,
+ /// Every `reconnect` request's params, in order, recorded across
+ /// every connection cloned from this state (shared via `Arc`).
+ reconnect_requests: Arc>>,
+ /// Snapshot to return from `subscribe` for a given channel, if any.
+ subscribe_snapshots:
+ Arc>>,
}
impl FakeHostState {
@@ -73,6 +89,9 @@ impl FakeHostState {
agents: vec![],
sessions: vec![],
automations: None,
+ handle_reconnect: None,
+ reconnect_requests: Arc::new(std::sync::Mutex::new(Vec::new())),
+ subscribe_snapshots: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
}
}
@@ -90,6 +109,15 @@ impl FakeHostState {
self.automations = Some(automations);
self
}
+
+ fn with_handle_reconnect(mut self, handler: ReconnectResponder) -> Self {
+ self.handle_reconnect = Some(handler);
+ self
+ }
+
+ fn reconnect_requests(&self) -> Vec {
+ self.reconnect_requests.lock().unwrap().clone()
+ }
}
/// Drive a single connection on the server side until the client closes.
@@ -201,10 +229,34 @@ fn handle_request(req: &JsonRpcRequest, state: &FakeHostState) -> serde_json::Va
"automations": state.automations,
})
}
- "reconnect" => serde_json::json!({
- "type": "replay",
- "actions": []
- }),
+ "reconnect" => {
+ let params: ahp_types::commands::ReconnectParams = req
+ .params
+ .clone()
+ .and_then(|p| serde_json::from_value(p).ok())
+ .expect("reconnect request must carry ReconnectParams");
+ state.reconnect_requests.lock().unwrap().push(params.clone());
+ let result = match &state.handle_reconnect {
+ Some(handler) => handler(¶ms),
+ // Default: reply with an empty replay for every
+ // requested channel so the supervisor moves on.
+ None => ahp_types::commands::ReconnectResult {
+ channels: params
+ .subscriptions
+ .iter()
+ .map(|sub| {
+ ahp_types::commands::ChannelRecovery::Replay(
+ ahp_types::commands::ChannelReplayRecovery {
+ channel: sub.channel.clone(),
+ actions: vec![],
+ },
+ )
+ })
+ .collect(),
+ },
+ };
+ serde_json::to_value(result).unwrap()
+ }
"listSessions" => serde_json::json!({ "items": state.sessions }),
"subscribe" => {
let resource = req
@@ -215,6 +267,9 @@ fn handle_request(req: &JsonRpcRequest, state: &FakeHostState) -> serde_json::Va
.and_then(|v| v.as_str())
.unwrap_or(ahp_types::ROOT_RESOURCE_URI)
.to_string();
+ if let Some(snapshot) = state.subscribe_snapshots.lock().unwrap().get(&resource) {
+ return serde_json::json!({ "snapshot": snapshot });
+ }
let seq = state.server_seq.load(Ordering::SeqCst);
serde_json::json!({
"snapshot": {
@@ -1074,6 +1129,296 @@ async fn add_host_cancellation_releases_pending_reservation() {
);
}
+// ─── Per-channel reconnect cursor regression tests ─────────────────────────
+//
+// These four tests prove the actual bug fix: a single connection-wide
+// reconnect watermark would let a fast channel's progress silently
+// skip a slower channel's undelivered actions on reconnect. Per-channel
+// cursors must keep every subscribed channel's replay checkpoint
+// completely independent.
+
+#[tokio::test]
+async fn reconnect_sends_independent_per_channel_cursors_a_fast_channel_never_advances_a_slower_one()
+{
+ let a = "ahp-canvas:/a".to_string();
+ let b = "ahp-canvas:/b".to_string();
+ let state = FakeHostState::new();
+
+ let multi = MultiHostClient::new();
+ multi
+ .add_host(HostConfig::new(
+ "divergent",
+ "divergent",
+ make_action_injecting_factory(state.clone(), b.clone(), 101),
+ ))
+ .await
+ .unwrap();
+ let id = HostId::new("divergent");
+ wait_for_state(&multi, &id, |s| s.is_connected(), 2000).await;
+
+ multi.subscribe(&id, a.clone()).await.unwrap();
+ multi.subscribe(&id, b.clone()).await.unwrap();
+
+ // B races ahead to serverSeq=101 via the injected notification
+ // while A never receives anything. A single connection-wide
+ // watermark would send `lastSeenServerSeq: 101` for every channel
+ // on the next reconnect and silently skip any of A's undelivered
+ // actions below that value; per-channel cursors must keep A at its
+ // own baseline instead.
+ wait_until(2000, || async {
+ multi.host(&id).await.map(|h| h.server_seq).unwrap_or(0) >= 101
+ })
+ .await;
+
+ multi.reconnect_host(&id).await.unwrap();
+ wait_until(2000, || async { state.reconnect_requests().len() >= 1 }).await;
+
+ let req = state.reconnect_requests().into_iter().next().unwrap();
+ let cursor_for = |ch: &str| {
+ req.subscriptions
+ .iter()
+ .find(|s| s.channel == ch)
+ .map(|s| s.last_seen_server_seq)
+ };
+ assert_eq!(cursor_for(&b), Some(101), "B's cursor should reflect its own progress");
+ assert_eq!(
+ cursor_for(&a),
+ Some(0),
+ "A's cursor must stay at its own baseline — never advanced by B"
+ );
+
+}
+
+#[tokio::test]
+async fn channel_replay_recovery_advances_only_that_channels_cursor() {
+ let a = "ahp-canvas:/a".to_string();
+ let b = "ahp-canvas:/b".to_string();
+
+ let a_for_handler = a.clone();
+ let state = FakeHostState::new().with_handle_reconnect(Arc::new(move |params| {
+ let channels = params
+ .subscriptions
+ .iter()
+ .map(|sub| {
+ if sub.channel == a_for_handler {
+ ahp_types::commands::ChannelRecovery::Replay(
+ ahp_types::commands::ChannelReplayRecovery {
+ channel: sub.channel.clone(),
+ actions: vec![
+ make_canvas_envelope(&sub.channel, 10),
+ make_canvas_envelope(&sub.channel, 11),
+ ],
+ },
+ )
+ } else {
+ ahp_types::commands::ChannelRecovery::Replay(
+ ahp_types::commands::ChannelReplayRecovery {
+ channel: sub.channel.clone(),
+ actions: vec![],
+ },
+ )
+ }
+ })
+ .collect();
+ ahp_types::commands::ReconnectResult { channels }
+ }));
+
+ let multi = MultiHostClient::new();
+ multi
+ .add_host(HostConfig::new(
+ "replay",
+ "replay",
+ make_basic_factory(state.clone()),
+ ))
+ .await
+ .unwrap();
+ let id = HostId::new("replay");
+ wait_for_state(&multi, &id, |s| s.is_connected(), 2000).await;
+
+ multi.subscribe(&id, a.clone()).await.unwrap();
+ multi.subscribe(&id, b.clone()).await.unwrap();
+
+ multi.reconnect_host(&id).await.unwrap();
+ wait_until(2000, || async { state.reconnect_requests().len() >= 1 }).await;
+
+ // The *next* reconnect after the replay must report A's advanced
+ // cursor (11) while B — which had an empty replay — stays at 0.
+ multi.reconnect_host(&id).await.unwrap();
+ wait_until(2000, || async { state.reconnect_requests().len() >= 2 }).await;
+
+ let reqs = state.reconnect_requests();
+ let req = reqs.last().unwrap();
+ let cursor_for = |ch: &str| {
+ req.subscriptions
+ .iter()
+ .find(|s| s.channel == ch)
+ .map(|s| s.last_seen_server_seq)
+ };
+ assert_eq!(cursor_for(&a), Some(11), "A's cursor should reflect the exhausted replay");
+ assert_eq!(cursor_for(&b), Some(0), "B's cursor must be untouched by A's replay");
+
+}
+
+#[tokio::test]
+async fn channel_snapshot_recovery_sets_that_channels_cursor_from_the_snapshot_from_seq_baseline() {
+ let a = "ahp-canvas:/a".to_string();
+
+ let state = FakeHostState::new().with_handle_reconnect(Arc::new(|params| {
+ let channels = params
+ .subscriptions
+ .iter()
+ .map(|sub| {
+ ahp_types::commands::ChannelRecovery::Snapshot(
+ ahp_types::commands::ChannelSnapshotRecovery {
+ channel: sub.channel.clone(),
+ snapshot: ahp_types::state::Snapshot {
+ resource: sub.channel.clone(),
+ state: ahp_types::state::SnapshotState::Canvas(Box::new(
+ make_canvas_state(&sub.channel),
+ )),
+ from_seq: 50,
+ },
+ },
+ )
+ })
+ .collect();
+ ahp_types::commands::ReconnectResult { channels }
+ }));
+
+ let multi = MultiHostClient::new();
+ multi
+ .add_host(HostConfig::new(
+ "snap",
+ "snap",
+ make_basic_factory(state.clone()),
+ ))
+ .await
+ .unwrap();
+ let id = HostId::new("snap");
+ wait_for_state(&multi, &id, |s| s.is_connected(), 2000).await;
+
+ multi.subscribe(&id, a.clone()).await.unwrap();
+
+ multi.reconnect_host(&id).await.unwrap();
+ wait_until(2000, || async { state.reconnect_requests().len() >= 1 }).await;
+ multi.reconnect_host(&id).await.unwrap();
+ wait_until(2000, || async { state.reconnect_requests().len() >= 2 }).await;
+
+ let reqs = state.reconnect_requests();
+ let req = reqs.last().unwrap();
+ assert_eq!(
+ req.subscriptions
+ .iter()
+ .find(|s| s.channel == a)
+ .map(|s| s.last_seen_server_seq),
+ Some(50)
+ );
+
+}
+
+#[tokio::test]
+async fn a_channel_reported_missing_on_reconnect_is_dropped_from_subscriptions_and_not_re_requested()
+{
+ let a = "ahp-canvas:/a".to_string();
+ let gone = "ahp-canvas:/gone".to_string();
+
+ let gone_for_handler = gone.clone();
+ let state = FakeHostState::new().with_handle_reconnect(Arc::new(move |params| {
+ let channels = params
+ .subscriptions
+ .iter()
+ .map(|sub| {
+ if sub.channel == gone_for_handler {
+ ahp_types::commands::ChannelRecovery::Missing(
+ ahp_types::commands::ChannelMissingRecovery {
+ channel: sub.channel.clone(),
+ },
+ )
+ } else {
+ ahp_types::commands::ChannelRecovery::Replay(
+ ahp_types::commands::ChannelReplayRecovery {
+ channel: sub.channel.clone(),
+ actions: vec![],
+ },
+ )
+ }
+ })
+ .collect();
+ ahp_types::commands::ReconnectResult { channels }
+ }));
+
+ let multi = MultiHostClient::new();
+ multi
+ .add_host(HostConfig::new(
+ "missing",
+ "missing",
+ make_basic_factory(state.clone()),
+ ))
+ .await
+ .unwrap();
+ let id = HostId::new("missing");
+ wait_for_state(&multi, &id, |s| s.is_connected(), 2000).await;
+
+ multi.subscribe(&id, a.clone()).await.unwrap();
+ multi.subscribe(&id, gone.clone()).await.unwrap();
+
+ multi.reconnect_host(&id).await.unwrap();
+ wait_until(2000, || async { state.reconnect_requests().len() >= 1 }).await;
+ wait_until(2000, || async {
+ !multi
+ .host(&id)
+ .await
+ .map(|h| h.subscriptions.contains(&gone))
+ .unwrap_or(true)
+ })
+ .await;
+ assert!(
+ multi
+ .host(&id)
+ .await
+ .unwrap()
+ .subscriptions
+ .contains(&a),
+ "A should remain subscribed"
+ );
+
+ multi.reconnect_host(&id).await.unwrap();
+ wait_until(2000, || async { state.reconnect_requests().len() >= 2 }).await;
+ let reqs = state.reconnect_requests();
+ let req = reqs.last().unwrap();
+ assert!(
+ !req.subscriptions.iter().any(|s| s.channel == gone),
+ "missing channel must not be re-requested"
+ );
+
+}
+
+fn make_canvas_state(instance_id: &str) -> ahp_types::state::CanvasState {
+ ahp_types::state::CanvasState {
+ instance_id: instance_id.to_string(),
+ extension_id: "fake".into(),
+ extension_name: None,
+ canvas_id: "fake-canvas".into(),
+ title: None,
+ status: None,
+ url: None,
+ }
+}
+
+fn make_canvas_envelope(channel: &str, server_seq: u64) -> ahp_types::actions::ActionEnvelope {
+ ahp_types::actions::ActionEnvelope {
+ channel: channel.to_string(),
+ action: ahp_types::actions::StateAction::CanvasStateChanged(
+ ahp_types::actions::CanvasStateChangedAction {
+ canvas: make_canvas_state(channel),
+ },
+ ),
+ server_seq,
+ origin: None,
+ rejection_reason: None,
+ }
+}
+
// ─── Helpers ────────────────────────────────────────────────────────────────
fn make_summary(uri: &str, title: &str, modified_at: i64) -> ahp_types::state::SessionSummary {
@@ -1151,6 +1496,105 @@ fn make_injecting_factory(
}
}
+/// Factory used by the divergent-per-channel-cursor regression test.
+/// Drives a basic fake host, then — shortly after the first
+/// `initialize`/`reconnect` response — fans in a single raw `action`
+/// notification for `channel` at `server_seq`. The short delay gives
+/// the test time to finish its `subscribe` round trips before the
+/// notification races in, mirroring the channel-B-races-ahead scenario
+/// the per-channel cursor fix is meant to cover.
+fn make_action_injecting_factory(
+ state: FakeHostState,
+ channel: String,
+ server_seq: u64,
+) -> impl Fn(
+ HostId,
+) -> std::pin::Pin<
+ Box> + Send>,
+> + Send
+ + Sync
+ + 'static {
+ let state = Arc::new(state);
+ move |_host_id| {
+ let state = state.clone();
+ let channel = channel.clone();
+ Box::pin(async move {
+ let (client_side, server_side) = pair();
+ tokio::spawn(drive_fake_host_with_action_injection(
+ server_side,
+ (*state).clone(),
+ channel,
+ server_seq,
+ ));
+ Ok(BoxedTransport::new(client_side))
+ })
+ }
+}
+
+async fn drive_fake_host_with_action_injection(
+ mut transport: MemTransport,
+ state: FakeHostState,
+ channel: String,
+ server_seq: u64,
+) {
+ let mut injection_ran = false;
+ loop {
+ let frame = match transport.recv().await {
+ Ok(Some(f)) => f,
+ _ => return,
+ };
+ let msg = match frame.into_parsed() {
+ Ok(m) => m,
+ Err(_) => continue,
+ };
+ if let JsonRpcMessage::Request(req) = msg {
+ let was_init = matches!(req.method.as_str(), "initialize" | "reconnect");
+ let result = handle_request(&req, &state);
+ let resp = JsonRpcMessage::SuccessResponse(JsonRpcSuccessResponse {
+ jsonrpc: JsonRpcVersion::V2,
+ id: req.id,
+ result: ahp_types::common::AnyValue::from(result),
+ });
+ if transport
+ .send(TransportMessage::encode(&resp).unwrap())
+ .await
+ .is_err()
+ {
+ return;
+ }
+ if !injection_ran && was_init {
+ injection_ran = true;
+ tokio::time::sleep(Duration::from_millis(30)).await;
+ let payload = serde_json::json!({
+ "channel": channel,
+ "action": {
+ "type": "canvas/stateChanged",
+ "canvas": {
+ "instanceId": "a",
+ "extensionId": "fake",
+ "canvasId": "fake-canvas",
+ },
+ },
+ "serverSeq": server_seq,
+ "origin": null,
+ });
+ let notif = JsonRpcMessage::Notification(JsonRpcNotification {
+ jsonrpc: JsonRpcVersion::V2,
+ method: "action".into(),
+ params: Some(ahp_types::common::AnyValue::from(payload)),
+ });
+ if transport
+ .send(TransportMessage::encode(¬if).unwrap())
+ .await
+ .is_err()
+ {
+ return;
+ }
+ }
+ }
+ }
+}
+
/// Factory used by the reconnect-replay test. The first connect responds
/// to `initialize` normally with a non-zero `serverSeq` and a single
/// subscription so the next connect chooses the `reconnect` arm. When
@@ -1209,19 +1653,23 @@ async fn drive_fake_host_replay(
// action carrying serverSeq=42 (advances past the
// pre-seeded 40).
serde_json::json!({
- "type": "replay",
- "actions": [
+ "channels": [
{
+ "kind": "replay",
"channel": ahp_types::ROOT_RESOURCE_URI,
- "action": {
- "type": "root/activeSessionsChanged",
- "activeSessions": 7
- },
- "serverSeq": 42,
- "origin": null,
+ "actions": [
+ {
+ "channel": ahp_types::ROOT_RESOURCE_URI,
+ "action": {
+ "type": "root/activeSessionsChanged",
+ "activeSessions": 7
+ },
+ "serverSeq": 42,
+ "origin": null,
+ }
+ ]
}
- ],
- "missing": []
+ ]
})
} else {
handle_request(&req, &state)
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift
index f50b96186..e12b23279 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift
@@ -4,10 +4,14 @@ import Foundation
// MARK: - Command Enums
-/// Discriminant for reconnect result types.
-public enum ReconnectResultType: String, Codable, Sendable {
+/// Discriminant for per-channel reconnect recovery outcomes.
+public enum ChannelRecoveryKind: String, Codable, Sendable {
+ /// The server replayed the channel's missed actions.
case replay = "replay"
+ /// The gap for this channel exceeded its replay buffer; a fresh snapshot is provided instead.
case snapshot = "snapshot"
+ /// The channel can no longer be resumed (e.g. disposed, or no longer permitted).
+ case missing = "missing"
}
/// How a new chat uses its source chat and turn.
@@ -526,16 +530,13 @@ public struct ReconnectParams: Codable, Sendable {
public var meta: [String: AnyCodable]?
/// Client identifier from the original connection
public var clientId: String
- /// Last `serverSeq` the client received
- public var lastSeenServerSeq: Int
- /// URIs the client was subscribed to
- public var subscriptions: [String]
+ /// Per-channel replay checkpoints for every channel the client is still subscribed to.
+ public var subscriptions: [ChannelReplayCursor]
enum CodingKeys: String, CodingKey {
case channel
case meta = "_meta"
case clientId
- case lastSeenServerSeq
case subscriptions
}
@@ -543,51 +544,100 @@ public struct ReconnectParams: Codable, Sendable {
channel: String,
meta: [String: AnyCodable]? = nil,
clientId: String,
- lastSeenServerSeq: Int,
- subscriptions: [String]
+ subscriptions: [ChannelReplayCursor]
) {
self.channel = channel
self.meta = meta
self.clientId = clientId
- self.lastSeenServerSeq = lastSeenServerSeq
self.subscriptions = subscriptions
}
}
-public struct ReconnectReplayResult: Codable, Sendable {
+public struct ChannelReplayCursor: Codable, Sendable {
+ /// The subscribed channel URI.
+ public var channel: String
+ /// `serverSeq` of the last action the client fully applied or safely
+ /// retained for `channel`, or the `fromSeq` of the `Snapshot` the client
+ /// last used to initialize `channel` (see {@link Snapshot.fromSeq}).
+ ///
+ /// `0` means the client has no baseline for `channel` yet — e.g. it
+ /// subscribed but the `subscribe`/`initialize` response snapshot (if any)
+ /// never arrived before the connection dropped. The server MUST NOT use
+ /// another channel's progress to advance this checkpoint, and MUST NOT
+ /// use it to seed the connection's global `serverSeq` identity (see
+ /// {@link InitializeResult.serverSeq}).
+ public var lastSeenServerSeq: Int
+
+ public init(
+ channel: String,
+ lastSeenServerSeq: Int
+ ) {
+ self.channel = channel
+ self.lastSeenServerSeq = lastSeenServerSeq
+ }
+}
+
+public struct ReconnectResult: Codable, Sendable {
+ /// One recovery outcome per requested subscription, in any order.
+ public var channels: [ChannelRecovery]
+
+ public init(
+ channels: [ChannelRecovery]
+ ) {
+ self.channels = channels
+ }
+}
+
+public struct ChannelReplayRecovery: Codable, Sendable {
/// Discriminant
- public var type: ReconnectResultType
- /// Missed action envelopes since `lastSeenServerSeq`
+ public var kind: ChannelRecoveryKind
+ /// The channel this recovery applies to.
+ public var channel: String
+ /// Missed action envelopes since the requested `lastSeenServerSeq`.
public var actions: [ActionEnvelope]
- /// URIs from `ReconnectParams.subscriptions` that the server cannot resume.
- /// This includes resources that no longer exist (e.g. disposed sessions or
- /// terminals) as well as resources the client is no longer permitted to
- /// observe. Clients SHOULD drop these from their local subscription set.
- public var missing: [String]
public init(
- type: ReconnectResultType,
- actions: [ActionEnvelope],
- missing: [String]
+ kind: ChannelRecoveryKind,
+ channel: String,
+ actions: [ActionEnvelope]
) {
- self.type = type
+ self.kind = kind
+ self.channel = channel
self.actions = actions
- self.missing = missing
}
}
-public struct ReconnectSnapshotResult: Codable, Sendable {
+public struct ChannelSnapshotRecovery: Codable, Sendable {
/// Discriminant
- public var type: ReconnectResultType
- /// Fresh snapshots for each subscription
- public var snapshots: [Snapshot]
+ public var kind: ChannelRecoveryKind
+ /// The channel this recovery applies to.
+ public var channel: String
+ /// Fresh snapshot the client MUST use as its new baseline for this channel.
+ public var snapshot: Snapshot
public init(
- type: ReconnectResultType,
- snapshots: [Snapshot]
+ kind: ChannelRecoveryKind,
+ channel: String,
+ snapshot: Snapshot
) {
- self.type = type
- self.snapshots = snapshots
+ self.kind = kind
+ self.channel = channel
+ self.snapshot = snapshot
+ }
+}
+
+public struct ChannelMissingRecovery: Codable, Sendable {
+ /// Discriminant
+ public var kind: ChannelRecoveryKind
+ /// The channel this recovery applies to.
+ public var channel: String
+
+ public init(
+ kind: ChannelRecoveryKind,
+ channel: String
+ ) {
+ self.kind = kind
+ self.channel = channel
}
}
@@ -2286,14 +2336,15 @@ public enum ChatMoveDestination: Codable, Sendable {
}
}
-// MARK: - ReconnectResult Union
+// MARK: - ChannelRecovery Union
-public enum ReconnectResult: Codable, Sendable {
- case replay(ReconnectReplayResult)
- case snapshot(ReconnectSnapshotResult)
+public enum ChannelRecovery: Codable, Sendable {
+ case replay(ChannelReplayRecovery)
+ case snapshot(ChannelSnapshotRecovery)
+ case missing(ChannelMissingRecovery)
private enum DiscriminantKey: String, CodingKey {
- case discriminant = "type"
+ case discriminant = "kind"
}
public init(from decoder: Decoder) throws {
@@ -2301,11 +2352,13 @@ public enum ReconnectResult: Codable, Sendable {
let discriminant = try container.decode(String.self, forKey: .discriminant)
switch discriminant {
case "replay":
- self = .replay(try ReconnectReplayResult(from: decoder))
+ self = .replay(try ChannelReplayRecovery(from: decoder))
case "snapshot":
- self = .snapshot(try ReconnectSnapshotResult(from: decoder))
+ self = .snapshot(try ChannelSnapshotRecovery(from: decoder))
+ case "missing":
+ self = .missing(try ChannelMissingRecovery(from: decoder))
default:
- throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ReconnectResult discriminant: \(discriminant)")
+ throw DecodingError.dataCorruptedError(forKey: .discriminant, in: container, debugDescription: "Unknown ChannelRecovery discriminant: \(discriminant)")
}
}
@@ -2313,6 +2366,7 @@ public enum ReconnectResult: Codable, Sendable {
switch self {
case .replay(let value): try value.encode(to: encoder)
case .snapshot(let value): try value.encode(to: encoder)
+ case .missing(let value): try value.encode(to: encoder)
}
}
}
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift
index 7e7b5e385..f1bb6d5ec 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift
@@ -277,29 +277,37 @@ public actor AHPClient {
///
/// This is *only* the typed handshake on the current connection — opening a
/// new transport, backoff, and generation tracking belong to a higher
- /// layer.
+ /// layer. `subscriptions` carries one independent replay cursor per
+ /// channel the caller is still subscribed to — recovery for each channel
+ /// is driven solely by that channel's own cursor, never by another
+ /// channel's progress.
@discardableResult
public func reconnect(
clientId: String,
- lastSeenServerSeq: Int,
- subscriptions: [String]
+ subscriptions: [ChannelReplayCursor]
) async throws -> ReconnectResult {
let params = ReconnectParams(
channel: RootResourceURI,
clientId: clientId,
- lastSeenServerSeq: lastSeenServerSeq,
subscriptions: subscriptions
)
let result: ReconnectResult = try await request(method: "reconnect", params: params)
- switch result {
- case .replay(let r):
- if let last = r.actions.last, last.serverSeq > self.lastSeenServerSeq {
- self.lastSeenServerSeq = last.serverSeq
- }
- case .snapshot(let r):
- let maxSeq = r.snapshots.map(\.fromSeq).max() ?? self.lastSeenServerSeq
- if maxSeq > self.lastSeenServerSeq {
- self.lastSeenServerSeq = maxSeq
+ // `lastSeenServerSeq` here is purely an informational high-water mark
+ // across all channels — it MUST NOT be used to decide reconnect
+ // eligibility or what to replay (that's exactly the per-channel
+ // cursor's job).
+ for recovery in result.channels {
+ switch recovery {
+ case .replay(let r):
+ if let last = r.actions.last, last.serverSeq > self.lastSeenServerSeq {
+ self.lastSeenServerSeq = last.serverSeq
+ }
+ case .snapshot(let r):
+ if r.snapshot.fromSeq > self.lastSeenServerSeq {
+ self.lastSeenServerSeq = r.snapshot.fromSeq
+ }
+ case .missing:
+ break
}
}
return result
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift
index 882a0b4c5..75edc4204 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift
@@ -70,6 +70,7 @@ internal final class HostRuntime: Sendable {
lastConnectedAt: nil,
protocolVersion: nil,
serverSeq: 0,
+ channelCursors: [:],
defaultDirectory: nil,
automations: nil,
rootState: RootState(agents: []),
@@ -313,9 +314,15 @@ internal final class HostRuntime: Sendable {
stateChanges: AsyncStream
) async throws -> ConnectionStreams {
// Decide between initialize and reconnect based on prior state.
+ // Reconnect requires an established protocol version (we've
+ // completed a handshake before) and at least one subscription to
+ // recover. The global `serverSeq` watermark is informational only
+ // and MUST NOT gate this decision — per-channel cursors (below)
+ // drive what gets replayed.
let priorSnapshot = await shared.internalState
- let canReconnect = priorSnapshot.serverSeq > 0 && !priorSnapshot.subscriptions.isEmpty
+ let canReconnect = priorSnapshot.protocolVersion != nil && !priorSnapshot.subscriptions.isEmpty
let priorSubscriptions = priorSnapshot.subscriptions
+ let priorCursors = priorSnapshot.channelCursors
let priorSeq = priorSnapshot.serverSeq
var initResult: InitializeResult? = nil
@@ -324,10 +331,12 @@ internal final class HostRuntime: Sendable {
if canReconnect {
do {
+ let cursors = priorSubscriptions.map { uri in
+ ChannelReplayCursor(channel: uri, lastSeenServerSeq: priorCursors[uri] ?? 0)
+ }
reconnectResult = try await client.reconnect(
clientId: clientId,
- lastSeenServerSeq: priorSeq,
- subscriptions: priorSubscriptions
+ subscriptions: cursors
)
} catch let error as AHPClientError {
if case .rpc = error {
@@ -379,6 +388,15 @@ internal final class HostRuntime: Sendable {
state.rootState = root
}
}
+ // A full (re)initialize discards any prior per-channel
+ // progress — the server has no continuity guarantee for
+ // it — and re-baselines every subscribed channel from
+ // its fresh snapshot (or `0` if stateless/absent).
+ state.channelCursors.removeAll()
+ for snap in init1.snapshots {
+ state.channelCursors[snap.resource] = snap.fromSeq
+ }
+ state.subscriptions = priorSubscriptions
}
if let list = summaries {
state.sessionSummaries.removeAll()
@@ -392,7 +410,7 @@ internal final class HostRuntime: Sendable {
}()
if let reconnectResult {
- await applyReconnectResult(reconnectResult, priorSubscriptions: priorSubscriptions)
+ await applyReconnectResult(reconnectResult)
await hostEventSink(.reconnectResult(config.id, reconnectResult))
}
@@ -403,47 +421,51 @@ internal final class HostRuntime: Sendable {
/// Apply the result of a successful `reconnect` call.
///
+ /// Each requested channel gets exactly one independent recovery outcome
+ /// — never a single connection-wide result — so a fast channel's
+ /// progress can't cause a slower channel's undelivered actions to be
+ /// skipped (or a missing channel to hide another channel's replay).
/// Replay envelopes use the same reducer path as live action frames so
/// host snapshots advance before consumers see fanned-out replay events.
- /// Snapshot results refresh root mirror state and prune subscriptions the
- /// server could not resume.
- private func applyReconnectResult(
- _ result: ReconnectResult,
- priorSubscriptions: [String]
- ) async {
- switch result {
- case .replay(let replay):
- for envelope in replay.actions {
- await applyAction(envelope)
- if config.fanOutReconnectReplayActions {
- await fanOut(HostSubscriptionEvent(
- hostId: config.id,
- resource: envelope.channel,
- event: .action(envelope)
- ))
+ /// Snapshot recoveries refresh root mirror state and set that channel's
+ /// cursor to the snapshot's `fromSeq` baseline. Missing channels are
+ /// dropped from the subscription set and their cursor entry removed so
+ /// they are not re-requested on a future reconnect.
+ private func applyReconnectResult(_ result: ReconnectResult) async {
+ var missingChannels: Set = []
+ for recovery in result.channels {
+ switch recovery {
+ case .replay(let replay):
+ for envelope in replay.actions {
+ await applyAction(envelope)
+ if config.fanOutReconnectReplayActions {
+ await fanOut(HostSubscriptionEvent(
+ hostId: config.id,
+ resource: envelope.channel,
+ event: .action(envelope)
+ ))
+ }
}
- }
- if !replay.missing.isEmpty {
+ case .snapshot(let snapshotRecovery):
await shared.update { state in
- state.subscriptions.removeAll { replay.missing.contains($0) }
- }
- }
- case .snapshot(let snapshotResult):
- await shared.update { state in
- var surviving: [String] = []
- surviving.reserveCapacity(snapshotResult.snapshots.count)
- for snapshot in snapshotResult.snapshots {
- if snapshot.fromSeq > state.serverSeq {
- state.serverSeq = snapshot.fromSeq
+ if snapshotRecovery.snapshot.fromSeq > state.serverSeq {
+ state.serverSeq = snapshotRecovery.snapshot.fromSeq
}
- if snapshot.resource == RootResourceURI,
- case .root(let root) = snapshot.state {
+ state.channelCursors[snapshotRecovery.channel] = snapshotRecovery.snapshot.fromSeq
+ if snapshotRecovery.channel == RootResourceURI,
+ case .root(let root) = snapshotRecovery.snapshot.state {
state.rootState = root
}
- surviving.append(snapshot.resource)
}
- state.subscriptions.removeAll { uri in
- priorSubscriptions.contains(uri) && !surviving.contains(uri)
+ case .missing(let missing):
+ missingChannels.insert(missing.channel)
+ }
+ }
+ if !missingChannels.isEmpty {
+ await shared.update { state in
+ state.subscriptions.removeAll { missingChannels.contains($0) }
+ for channel in missingChannels {
+ state.channelCursors.removeValue(forKey: channel)
}
}
}
@@ -624,6 +646,14 @@ internal final class HostRuntime: Sendable {
if envelope.serverSeq > state.serverSeq {
state.serverSeq = envelope.serverSeq
}
+ // Advance only the matching channel's cursor — never another
+ // channel's — so a fast channel can never cause a slower
+ // channel's undelivered actions to be skipped on the next
+ // reconnect.
+ let priorCursor = state.channelCursors[envelope.channel] ?? 0
+ if envelope.serverSeq > priorCursor {
+ state.channelCursors[envelope.channel] = envelope.serverSeq
+ }
// Best-effort root state mirror update via the existing pure
// reducer. Non-root channels slip through without effect — that's
// the same posture as the Rust SDK.
@@ -641,7 +671,7 @@ internal final class HostRuntime: Sendable {
}
do {
let (result, _) = try await client.subscribe(uri)
- await shared.appendSubscription(uri)
+ await shared.appendSubscription(uri, baselineServerSeq: result.snapshot?.fromSeq)
return .success(result)
} catch let error as AHPClientError {
return .failure(.client(error))
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift
index cc5e3676e..c0608723b 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift
@@ -19,7 +19,18 @@ internal struct HostInternal {
var lastError: String?
var lastConnectedAt: Date?
var protocolVersion: String?
+ /// Highest `serverSeq` ever observed across any channel. Informational
+ /// only (surfaced on `HostHandle` for diagnostics) — it is never used to
+ /// decide reconnect eligibility or what to replay. Per-channel recovery
+ /// uses `channelCursors` instead, so a fast channel's progress can never
+ /// cause a slower channel's undelivered actions to be skipped.
var serverSeq: Int
+ /// Per-channel replay cursors: the highest `serverSeq` fully applied (or
+ /// snapshot baseline established) for each currently-subscribed channel.
+ /// Keyed by channel URI. Drives the `subscriptions` sent on
+ /// `AHPClient.reconnect` and is only ever advanced by envelopes or
+ /// snapshots for the matching channel — never cross-channel.
+ var channelCursors: [String: Int]
var defaultDirectory: String?
var automations: AutomationCapabilities?
var rootState: RootState
@@ -87,16 +98,28 @@ internal actor HostShared {
body(&internalState)
}
- /// Convenience: append a subscription URI if not already present.
- func appendSubscription(_ uri: String) {
+ /// Convenience: append a subscription URI if not already present, and
+ /// seed/raise its replay cursor from `baselineServerSeq` (typically the
+ /// subscribe response's `snapshot.fromSeq`, when present). Never lowers
+ /// an already-tracked cursor — a re-subscribe shouldn't rewind a channel
+ /// that's already caught up further via reconnect/replay.
+ func appendSubscription(_ uri: String, baselineServerSeq: Int? = nil) {
if !internalState.subscriptions.contains(uri) {
internalState.subscriptions.append(uri)
}
+ if let current = internalState.channelCursors[uri] {
+ if let baselineServerSeq, baselineServerSeq > current {
+ internalState.channelCursors[uri] = baselineServerSeq
+ }
+ } else {
+ internalState.channelCursors[uri] = baselineServerSeq ?? 0
+ }
}
- /// Convenience: remove a subscription URI.
+ /// Convenience: remove a subscription URI and its replay cursor.
func removeSubscription(_ uri: String) {
internalState.subscriptions.removeAll { $0 == uri }
+ internalState.channelCursors.removeValue(forKey: uri)
}
/// Convenience: read the last error string.
diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostClientTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostClientTests.swift
index e8c46b106..7a00bab94 100644
--- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostClientTests.swift
+++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostClientTests.swift
@@ -1241,26 +1241,56 @@ private func reconnectResult(for mode: ReconnectResponseMode) -> [String: Any] {
switch mode {
case .emptyReplay:
return [
- "type": "replay",
- "actions": [],
- "missing": [],
+ "channels": [
+ channelReplayRecoveryJSON(channel: RootResourceURI, actions: [])
+ ],
] as [String: Any]
case .replayWithMissingAndLiveAction:
return [
- "type": "replay",
- "actions": [
- actionEnvelopeJSON(serverSeq: 42, activeSessions: 7)
+ "channels": [
+ channelReplayRecoveryJSON(
+ channel: RootResourceURI,
+ actions: [actionEnvelopeJSON(serverSeq: 42, activeSessions: 7)]
+ ),
+ channelMissingRecoveryJSON(channel: "copilot:/missing"),
],
- "missing": ["copilot:/missing"],
] as [String: Any]
case .snapshotRootOnly:
return [
- "type": "snapshot",
- "snapshots": [rootSnapshot(fromSeq: 77, activeSessions: 9)],
+ "channels": [
+ channelSnapshotRecoveryJSON(
+ channel: RootResourceURI,
+ snapshot: rootSnapshot(fromSeq: 77, activeSessions: 9)
+ ),
+ channelMissingRecoveryJSON(channel: "copilot:/missing"),
+ ],
] as [String: Any]
}
}
+private func channelReplayRecoveryJSON(channel: String, actions: [[String: Any]]) -> [String: Any] {
+ [
+ "kind": "replay",
+ "channel": channel,
+ "actions": actions,
+ ]
+}
+
+private func channelSnapshotRecoveryJSON(channel: String, snapshot: [String: Any]) -> [String: Any] {
+ [
+ "kind": "snapshot",
+ "channel": channel,
+ "snapshot": snapshot,
+ ]
+}
+
+private func channelMissingRecoveryJSON(channel: String) -> [String: Any] {
+ [
+ "kind": "missing",
+ "channel": channel,
+ ]
+}
+
private func rootSnapshot(fromSeq: Int, activeSessions: Int) -> [String: Any] {
[
"resource": RootResourceURI,
@@ -1353,7 +1383,13 @@ private func startDispatchRecordingHost(
case "reconnect":
_ = await sendResponse(
to: id,
- result: ["type": "replay", "actions": [], "missing": []] as [String: Any],
+ result: [
+ "channels": [[
+ "kind": "replay",
+ "channel": RootResourceURI,
+ "actions": [],
+ ] as [String: Any]],
+ ] as [String: Any],
on: transport
)
case "listSessions":
diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostTestHelpers.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostTestHelpers.swift
index 948994f74..6347c5f1a 100644
--- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostTestHelpers.swift
+++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/MultiHostTestHelpers.swift
@@ -122,9 +122,11 @@ struct FakeHost {
]
case "reconnect":
return [
- "type": "replay",
- "actions": [],
- "missing": [],
+ "channels": [[
+ "kind": "replay",
+ "channel": RootResourceURI,
+ "actions": [],
+ ] as [String: Any]],
] as [String: Any]
case "listSessions":
let items = sessionSummariesToJSON(state.sessions)
diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/PerChannelReplayCursorTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/PerChannelReplayCursorTests.swift
new file mode 100644
index 000000000..5553d202a
--- /dev/null
+++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/PerChannelReplayCursorTests.swift
@@ -0,0 +1,357 @@
+// PerChannelReplayCursorTests — regression coverage for independent
+// per-channel reconnect cursors.
+//
+// Mirrors the four new scenarios added to `clients/typescript/test/
+// hosts.test.ts` under "Per-channel reconnect/replay cursors": a fast
+// channel must never advance a slower sibling's cursor, replay/snapshot
+// recoveries must only move the matching channel's cursor, and a channel
+// reported `missing` must be dropped from subscriptions and never
+// re-requested.
+
+import XCTest
+import AgentHostProtocol
+@testable import AgentHostProtocolClient
+
+final class PerChannelReplayCursorTests: XCTestCase {
+ private static let channelA = "ahp-canvas:/a"
+ private static let channelB = "ahp-canvas:/b"
+
+ /// A fast channel (B) racing ahead of a slow channel (A) must not cause
+ /// A's per-channel cursor to advance — the reconnect request must carry
+ /// A's own (unseen) baseline, not B's.
+ func testFastChannelNeverAdvancesSlowerSiblingsCursor() async throws {
+ let state = PerChannelFakeHostState()
+ await state.setInjectAfterInit { transport in
+ try? await Task.sleep(for: .milliseconds(20))
+ _ = await sendCanvasActionNotification(channel: Self.channelB, serverSeq: 101, on: transport)
+ }
+
+ let multi = MultiHostClient()
+ let config = HostConfig(
+ id: "divergent",
+ label: "divergent",
+ transportFactory: makePerChannelFakeHostFactory(state: state)
+ )
+ _ = try await multi.add(config)
+ await waitForHostState(multi, id: "divergent") { $0.isConnected }
+ _ = try await multi.subscribe(host: "divergent", uri: Self.channelA)
+ _ = try await multi.subscribe(host: "divergent", uri: Self.channelB)
+ // Give the injected B-channel notification time to be applied
+ // before triggering the reconnect.
+ try? await Task.sleep(for: .milliseconds(60))
+
+ try await multi.reconnect("divergent")
+ await waitUntil { await state.reconnectRequests.count >= 1 }
+
+ let reconnectRequests1 = await state.reconnectRequests
+ let req = try XCTUnwrap(reconnectRequests1.first)
+ let cursorFor = { (channel: String) in
+ req.subscriptions.first { $0.channel == channel }?.lastSeenServerSeq
+ }
+ XCTAssertEqual(cursorFor(Self.channelB), 101, "B's cursor should reflect its own progress")
+ XCTAssertEqual(cursorFor(Self.channelA), 0, "A's cursor must stay at its own baseline — never advanced by B")
+
+ await multi.shutdown()
+ }
+
+ /// A replay recovery for one channel must only advance that channel's
+ /// cursor; an untouched sibling channel (with an empty replay) must stay
+ /// at its baseline.
+ func testReplayRecoveryAdvancesOnlyItsOwnChannelsCursor() async throws {
+ let state = PerChannelFakeHostState()
+ await state.setHandleReconnect { params in
+ let channels: [ChannelRecovery] = params.subscriptions.map { sub in
+ if sub.channel == Self.channelA {
+ let actions = [10, 11].map { seq in
+ ActionEnvelope(
+ channel: Self.channelA,
+ action: .canvasStateChanged(CanvasStateChangedAction(
+ type: .canvasStateChanged,
+ canvas: CanvasState(instanceId: "a", extensionId: "fake", canvasId: "fake-canvas")
+ )),
+ serverSeq: seq
+ )
+ }
+ return .replay(ChannelReplayRecovery(kind: .replay, channel: Self.channelA, actions: actions))
+ }
+ return .replay(ChannelReplayRecovery(kind: .replay, channel: sub.channel, actions: []))
+ }
+ return ReconnectResult(channels: channels)
+ }
+
+ let multi = MultiHostClient()
+ let config = HostConfig(
+ id: "replay",
+ label: "replay",
+ transportFactory: makePerChannelFakeHostFactory(state: state)
+ )
+ _ = try await multi.add(config)
+ await waitForHostState(multi, id: "replay") { $0.isConnected }
+ _ = try await multi.subscribe(host: "replay", uri: Self.channelA)
+ _ = try await multi.subscribe(host: "replay", uri: Self.channelB)
+
+ try await multi.reconnect("replay")
+ await waitUntil { await state.reconnectRequests.count >= 1 }
+
+ // The *next* reconnect must report A's advanced cursor (11) while B
+ // — which had an empty replay — stays at 0.
+ try await multi.reconnect("replay")
+ await waitUntil { await state.reconnectRequests.count >= 2 }
+
+ let reconnectRequestsSnapshot = await state.reconnectRequests
+ let req = try XCTUnwrap(reconnectRequestsSnapshot.last)
+ let cursorFor = { (channel: String) in
+ req.subscriptions.first { $0.channel == channel }?.lastSeenServerSeq
+ }
+ XCTAssertEqual(cursorFor(Self.channelA), 11, "A's cursor should reflect the exhausted replay")
+ XCTAssertEqual(cursorFor(Self.channelB), 0, "B's cursor must be untouched by A's replay")
+
+ await multi.shutdown()
+ }
+
+ /// A snapshot recovery must set that channel's cursor to the snapshot's
+ /// `fromSeq`, becoming the new baseline for subsequent reconnects.
+ func testSnapshotRecoverySetsCursorFromFromSeqBaseline() async throws {
+ let state = PerChannelFakeHostState()
+ await state.setHandleReconnect { params in
+ let channels: [ChannelRecovery] = params.subscriptions.map { sub in
+ .snapshot(ChannelSnapshotRecovery(
+ kind: .snapshot,
+ channel: sub.channel,
+ snapshot: Snapshot(
+ resource: sub.channel,
+ state: .canvas(CanvasState(instanceId: "a", extensionId: "fake", canvasId: "fake-canvas")),
+ fromSeq: 50
+ )
+ ))
+ }
+ return ReconnectResult(channels: channels)
+ }
+
+ let multi = MultiHostClient()
+ let config = HostConfig(
+ id: "snap",
+ label: "snap",
+ transportFactory: makePerChannelFakeHostFactory(state: state)
+ )
+ _ = try await multi.add(config)
+ await waitForHostState(multi, id: "snap") { $0.isConnected }
+ _ = try await multi.subscribe(host: "snap", uri: Self.channelA)
+
+ try await multi.reconnect("snap")
+ await waitUntil { await state.reconnectRequests.count >= 1 }
+ try await multi.reconnect("snap")
+ await waitUntil { await state.reconnectRequests.count >= 2 }
+
+ let reconnectRequestsSnapshot = await state.reconnectRequests
+ let req = try XCTUnwrap(reconnectRequestsSnapshot.last)
+ XCTAssertEqual(req.subscriptions.first { $0.channel == Self.channelA }?.lastSeenServerSeq, 50)
+
+ await multi.shutdown()
+ }
+
+ /// A channel reported `missing` on reconnect must be dropped from the
+ /// host's subscriptions and must never be re-requested on a later
+ /// reconnect, while unrelated channels remain subscribed.
+ func testMissingChannelIsDroppedAndNeverReRequested() async throws {
+ let gone = "ahp-canvas:/gone"
+ let state = PerChannelFakeHostState()
+ await state.setHandleReconnect { params in
+ let channels: [ChannelRecovery] = params.subscriptions.map { sub in
+ if sub.channel == gone {
+ return .missing(ChannelMissingRecovery(kind: .missing, channel: gone))
+ }
+ return .replay(ChannelReplayRecovery(kind: .replay, channel: sub.channel, actions: []))
+ }
+ return ReconnectResult(channels: channels)
+ }
+
+ let multi = MultiHostClient()
+ let config = HostConfig(
+ id: "missing",
+ label: "missing",
+ transportFactory: makePerChannelFakeHostFactory(state: state)
+ )
+ _ = try await multi.add(config)
+ await waitForHostState(multi, id: "missing") { $0.isConnected }
+ _ = try await multi.subscribe(host: "missing", uri: Self.channelA)
+ _ = try await multi.subscribe(host: "missing", uri: gone)
+
+ try await multi.reconnect("missing")
+ await waitUntil { await state.reconnectRequests.count >= 1 }
+ await waitUntil {
+ let subscriptions = await multi.host("missing")?.subscriptions ?? [gone]
+ return !subscriptions.contains(gone)
+ }
+ let afterFirstReconnect = await multi.host("missing")
+ XCTAssertTrue(afterFirstReconnect?.subscriptions.contains(Self.channelA) ?? false, "A should remain subscribed")
+
+ try await multi.reconnect("missing")
+ await waitUntil { await state.reconnectRequests.count >= 2 }
+ let reconnectRequestsSnapshot = await state.reconnectRequests
+ let req = try XCTUnwrap(reconnectRequestsSnapshot.last)
+ XCTAssertFalse(req.subscriptions.contains { $0.channel == gone }, "missing channel must not be re-requested")
+
+ await multi.shutdown()
+ }
+}
+
+// MARK: - Per-channel fake host harness
+
+/// Server-side state for `drivePerChannelFakeHost`: records every decoded
+/// `reconnect` request (in order) and allows tests to override the
+/// `ReconnectResult` returned, and to inject an action notification shortly
+/// after the first `initialize`/`reconnect` response.
+actor PerChannelFakeHostState {
+ private(set) var reconnectRequests: [ReconnectParams] = []
+ private var handleReconnect: (@Sendable (ReconnectParams) -> ReconnectResult)?
+ private var injectAfterInit: (@Sendable (InMemoryTransport) async -> Void)?
+
+ func setHandleReconnect(_ handler: @escaping @Sendable (ReconnectParams) -> ReconnectResult) {
+ handleReconnect = handler
+ }
+
+ func setInjectAfterInit(_ handler: @escaping @Sendable (InMemoryTransport) async -> Void) {
+ injectAfterInit = handler
+ }
+
+ func recordReconnect(_ params: ReconnectParams) {
+ reconnectRequests.append(params)
+ }
+
+ func buildReconnectResult(for params: ReconnectParams) -> ReconnectResult {
+ if let handleReconnect {
+ return handleReconnect(params)
+ }
+ // Default: an empty replay for every requested channel.
+ let channels: [ChannelRecovery] = params.subscriptions.map {
+ .replay(ChannelReplayRecovery(kind: .replay, channel: $0.channel, actions: []))
+ }
+ return ReconnectResult(channels: channels)
+ }
+
+ func runInjection(on transport: InMemoryTransport) async {
+ guard let injectAfterInit else { return }
+ await injectAfterInit(transport)
+ }
+}
+
+/// Build a transport factory that, on every call, opens a fresh
+/// `InMemoryTransport.pair()` and starts `drivePerChannelFakeHost` driving
+/// the server side.
+func makePerChannelFakeHostFactory(state: PerChannelFakeHostState) -> HostTransportFactory {
+ { _ in
+ let (clientSide, serverSide) = InMemoryTransport.pair()
+ Task {
+ await drivePerChannelFakeHost(transport: serverSide, state: state)
+ }
+ return clientSide
+ }
+}
+
+/// Drive one fake-host connection, responding to `initialize`, `reconnect`,
+/// `listSessions`, and `subscribe`. Other requests echo an empty `{}`
+/// result. Runs `state`'s injection callback once, after the first
+/// `initialize` or `reconnect` response.
+private func drivePerChannelFakeHost(transport: InMemoryTransport, state: PerChannelFakeHostState) async {
+ let decoder = JSONDecoder()
+ let encoder = JSONEncoder()
+ var injectionRan = false
+ while !Task.isCancelled {
+ let frame: TransportMessage?
+ do {
+ frame = try await transport.recv()
+ } catch {
+ return
+ }
+ guard let frame else { return }
+ guard case .text(let text) = frame,
+ let data = text.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let id = object["id"] as? Int,
+ let method = object["method"] as? String
+ else { continue }
+
+ let result: Any
+ switch method {
+ case "initialize":
+ result = [
+ "protocolVersion": "0.1.0",
+ "serverSeq": 0,
+ "snapshots": [[
+ "resource": RootResourceURI,
+ "state": ["agents": [], "activeSessions": 0] as [String: Any],
+ "fromSeq": 0,
+ ] as [String: Any]],
+ ] as [String: Any]
+ case "reconnect":
+ guard let paramsObject = object["params"],
+ let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject),
+ let params = try? decoder.decode(ReconnectParams.self, from: paramsData)
+ else { continue }
+ await state.recordReconnect(params)
+ let reconnectResult = await state.buildReconnectResult(for: params)
+ guard let encoded = try? encoder.encode(reconnectResult),
+ let obj = try? JSONSerialization.jsonObject(with: encoded)
+ else { continue }
+ result = obj
+ case "listSessions":
+ result = ["items": []] as [String: Any]
+ case "subscribe":
+ // The fake server doesn't enforce real subscriptions; no
+ // snapshot means the subscribed channel starts at cursor 0.
+ result = [:] as [String: Any]
+ default:
+ result = [:] as [String: Any]
+ }
+
+ let response: [String: Any] = ["jsonrpc": "2.0", "id": id, "result": result]
+ guard let respData = try? JSONSerialization.data(withJSONObject: response),
+ let respText = String(data: respData, encoding: .utf8)
+ else { continue }
+ do {
+ try await transport.send(.text(respText))
+ } catch {
+ return
+ }
+
+ if !injectionRan && (method == "initialize" || method == "reconnect") {
+ injectionRan = true
+ await state.runInjection(on: transport)
+ }
+ }
+}
+
+/// Send an `action` notification for a non-root `CanvasStateChanged`
+/// envelope on `channel`, for per-channel-cursor tests.
+private func sendCanvasActionNotification(
+ channel: String,
+ serverSeq: Int,
+ on transport: InMemoryTransport
+) async -> Bool {
+ let envelope = ActionEnvelope(
+ channel: channel,
+ action: .canvasStateChanged(CanvasStateChangedAction(
+ type: .canvasStateChanged,
+ canvas: CanvasState(instanceId: channel, extensionId: "fake", canvasId: "fake-canvas")
+ )),
+ serverSeq: serverSeq
+ )
+ guard let data = try? JSONEncoder().encode(envelope),
+ let paramsObject = try? JSONSerialization.jsonObject(with: data)
+ else { return false }
+ let notification: [String: Any] = [
+ "jsonrpc": "2.0",
+ "method": "action",
+ "params": paramsObject,
+ ]
+ guard let notifData = try? JSONSerialization.data(withJSONObject: notification),
+ let notifText = String(data: notifData, encoding: .utf8)
+ else { return false }
+ do {
+ try await transport.send(.text(notifText))
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift
index fc3031a3f..5e941cd50 100644
--- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift
+++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift
@@ -188,6 +188,10 @@ final class TypesRoundTripFixtureTests: XCTestCase {
return try reencode(dec.decode(Implementation.self, from: inputData))
case "InitializeResult":
return try reencode(dec.decode(InitializeResult.self, from: inputData))
+ case "ReconnectParams":
+ return try reencode(dec.decode(ReconnectParams.self, from: inputData))
+ case "ReconnectResult":
+ return try reencode(dec.decode(ReconnectResult.self, from: inputData))
case "ChatSource":
return try reencode(dec.decode(ChatSource.self, from: inputData))
case "Snapshot":
diff --git a/clients/typescript/src/client/client.ts b/clients/typescript/src/client/client.ts
index 36066b583..888f7d888 100644
--- a/clients/typescript/src/client/client.ts
+++ b/clients/typescript/src/client/client.ts
@@ -13,6 +13,7 @@
import type { StateAction } from '../types/actions.js';
import type {
+ ChannelReplayCursor,
DispatchActionParams,
InitializeParams,
InitializeResult,
@@ -367,13 +368,11 @@ export class AhpClient {
/** Re-establish a dropped connection. */
async reconnect(args: {
clientId: string;
- lastSeenServerSeq: number;
- subscriptions: readonly URI[];
+ subscriptions: readonly ChannelReplayCursor[];
}): Promise {
const params: ReconnectParams = {
channel: 'ahp-root://',
clientId: args.clientId,
- lastSeenServerSeq: args.lastSeenServerSeq,
subscriptions: [...args.subscriptions],
};
return this.request('reconnect', params);
diff --git a/clients/typescript/src/client/hosts/runtime.ts b/clients/typescript/src/client/hosts/runtime.ts
index 504924b56..94a1053d5 100644
--- a/clients/typescript/src/client/hosts/runtime.ts
+++ b/clients/typescript/src/client/hosts/runtime.ts
@@ -18,10 +18,11 @@ import type { ActionEnvelope } from '../../types/common/actions.js';
import type { URI } from '../../types/common/state.js';
import type { Snapshot } from '../../types/common/state.js';
import type {
+ ChannelReplayCursor,
ReconnectResult,
SubscribeResult,
} from '../../types/common/commands.js';
-import { ReconnectResultType } from '../../types/common/commands.js';
+import { ChannelRecoveryKind } from '../../types/common/commands.js';
import type { ListSessionsResult } from '../../types/channels-root/commands.js';
import type { RootState } from '../../types/channels-root/state.js';
import type { SessionSummary } from '../../types/channels-session/state.js';
@@ -70,7 +71,23 @@ export interface HostShared {
lastError: Error | null;
lastConnectedAt: number | null;
protocolVersion: string | null;
+ /**
+ * Highest `serverSeq` ever observed across any channel. Informational
+ * only (surfaced on {@link HostHandle} for diagnostics) — it is never
+ * used to decide what to replay on reconnect. Per-channel recovery
+ * uses {@link channelCursors} instead, so a fast channel's `serverSeq`
+ * can never cause a slower channel's undelivered actions to be
+ * skipped.
+ */
serverSeq: number;
+ /**
+ * Per-channel replay cursors: the highest `serverSeq` fully applied
+ * (or snapshot baseline established) for each currently-subscribed
+ * channel. Keyed by channel URI. Drives the `subscriptions` sent on
+ * {@link AhpClient.reconnect} and is only ever advanced by envelopes
+ * or snapshots for the matching channel — never cross-channel.
+ */
+ channelCursors: Map;
defaultDirectory: string | null;
automations: AutomationCapabilities | null;
rootState: RootState;
@@ -102,6 +119,7 @@ export function makeInitialShared(
lastConnectedAt: null,
protocolVersion: null,
serverSeq: 0,
+ channelCursors: new Map(),
defaultDirectory: null,
automations: null,
rootState: { agents: [] },
@@ -392,7 +410,7 @@ export class HostRuntime {
throw new HostNotConnectedError(this.shared.id);
}
const { result } = await client.subscribe(uri);
- this.trackSubscription(uri);
+ this.trackSubscription(uri, result.snapshot?.fromSeq);
return result;
}
@@ -563,14 +581,17 @@ export class HostRuntime {
let success = false;
try {
const prior = {
- serverSeq: this.shared.serverSeq,
subscriptions: [...this.shared.subscriptions],
};
- const canReconnect = prior.serverSeq > 0 && prior.subscriptions.length > 0;
+ // Reconnect is only meaningful once we've completed an initial
+ // handshake (so the server has an established client identity
+ // and per-channel cursors to look up) and we actually have
+ // channels to recover.
+ const canReconnect = this.shared.protocolVersion !== null && prior.subscriptions.length > 0;
let reconnectResult: ReconnectResult | null = null;
let initSnapshots: Snapshot[] | null = null;
- let initServerSeq = prior.serverSeq;
+ let initServerSeq = this.shared.serverSeq;
let initProtocolVersion: string | null = null;
let initDefaultDirectory: string | null = null;
let initAutomations = this.shared.automations;
@@ -581,8 +602,12 @@ export class HostRuntime {
const reconnectRes = await raceWithAbort(
client.reconnect({
clientId: this.shared.clientId,
- lastSeenServerSeq: prior.serverSeq,
- subscriptions: prior.subscriptions,
+ subscriptions: prior.subscriptions.map(
+ (uri): ChannelReplayCursor => ({
+ channel: uri,
+ lastSeenServerSeq: this.shared.channelCursors.get(uri) ?? 0,
+ }),
+ ),
}),
cancelSignal,
);
@@ -649,31 +674,34 @@ export class HostRuntime {
// Apply replay envelopes BEFORE transitioning to `connected` so
// consumers observing the `connected` host event already see the
// catch-up applied to state and event streams.
- let postReplaySubscriptions: string[] | null = null;
const replayEnvelopes: ActionEnvelope[] = [];
- let snapshotPrunedSubscriptions: string[] | null = null;
+ const missingChannels = new Set();
+ const snapshotCursorUpdates = new Map();
if (reconnectResult !== null) {
- if (reconnectResult.type === ReconnectResultType.Replay) {
- for (const env of reconnectResult.actions) replayEnvelopes.push(env);
- if (reconnectResult.missing.length > 0) {
- const missing = new Set(reconnectResult.missing);
- postReplaySubscriptions = this.shared.subscriptions.filter(u => !missing.has(u));
- }
- } else {
- // Snapshot variant: refresh root state from the matching
- // snapshot, then drop subscriptions that were in prior set
- // but are absent from the returned snapshot list.
- const surviving = new Set(reconnectResult.snapshots.map(s => s.resource));
- const priorSet = new Set(prior.subscriptions);
- snapshotPrunedSubscriptions = this.shared.subscriptions.filter(
- u => surviving.has(u) || !priorSet.has(u),
- );
- for (const snap of reconnectResult.snapshots) {
- if (snap.fromSeq > initServerSeq) initServerSeq = snap.fromSeq;
- if (snap.resource === ROOT_RESOURCE_URI) {
- this.shared.rootState = (snap.state as RootState) ?? EMPTY_ROOT_STATE;
- }
+ // Each requested channel gets exactly one independent recovery
+ // outcome — never a single connection-wide result — so a fast
+ // channel's progress can't cause a slower channel's undelivered
+ // actions to be skipped (or a missing channel to hide another
+ // channel's replay).
+ for (const recovery of reconnectResult.channels) {
+ switch (recovery.kind) {
+ case ChannelRecoveryKind.Replay:
+ for (const env of recovery.actions) {
+ replayEnvelopes.push(env);
+ if (env.serverSeq > initServerSeq) initServerSeq = env.serverSeq;
+ }
+ break;
+ case ChannelRecoveryKind.Snapshot:
+ snapshotCursorUpdates.set(recovery.channel, recovery.snapshot.fromSeq);
+ if (recovery.snapshot.fromSeq > initServerSeq) initServerSeq = recovery.snapshot.fromSeq;
+ if (recovery.channel === ROOT_RESOURCE_URI) {
+ this.shared.rootState = (recovery.snapshot.state as RootState) ?? EMPTY_ROOT_STATE;
+ }
+ break;
+ case ChannelRecoveryKind.Missing:
+ missingChannels.add(recovery.channel);
+ break;
}
}
}
@@ -686,23 +714,37 @@ export class HostRuntime {
if (this.shared.serverSeq < initServerSeq) {
this.shared.serverSeq = initServerSeq;
}
+ if (reconnectResult !== null) {
+ for (const [uri, seq] of snapshotCursorUpdates) {
+ this.shared.channelCursors.set(uri, seq);
+ }
+ // Drop channels the server reports as unrecoverable; keep
+ // everything else (replay/snapshot outcomes don't change the
+ // subscription set, only the cursor).
+ this.shared.subscriptions = prior.subscriptions.filter(u => !missingChannels.has(u));
+ for (const uri of missingChannels) this.shared.channelCursors.delete(uri);
+ }
if (initSnapshots !== null) {
+ // A full (re)initialize discards any prior per-channel
+ // progress — the server has no continuity guarantee for it —
+ // and re-baselines every subscribed channel from its fresh
+ // snapshot (or `0` if the channel is stateless).
+ this.shared.channelCursors.clear();
+ for (const snap of initSnapshots) {
+ this.shared.channelCursors.set(snap.resource, snap.fromSeq);
+ }
const rootSnap = initSnapshots.find(s => s.resource === ROOT_RESOURCE_URI);
if (rootSnap) this.shared.rootState = (rootSnap.state as RootState) ?? EMPTY_ROOT_STATE;
if (initProtocolVersion) this.shared.protocolVersion = initProtocolVersion;
this.shared.defaultDirectory = initDefaultDirectory;
this.shared.automations = initAutomations;
this.shared.completionTriggerCharacters = [...initCompletionTriggers];
+ this.shared.subscriptions = prior.subscriptions;
}
if (summaries !== null) {
this.shared.sessionSummaries.clear();
for (const s of summaries.items) this.shared.sessionSummaries.set(s.resource, s);
}
- if (postReplaySubscriptions !== null) {
- this.shared.subscriptions = postReplaySubscriptions;
- } else if (snapshotPrunedSubscriptions !== null) {
- this.shared.subscriptions = snapshotPrunedSubscriptions;
- }
// Mirror the new generation + client into the shared handle source.
this.handleSource.generation = this.shared.generation;
@@ -826,6 +868,13 @@ export class HostRuntime {
if (envelope.serverSeq > this.shared.serverSeq) {
this.shared.serverSeq = envelope.serverSeq;
}
+ // Advance only the matching channel's cursor — never another
+ // channel's — so a fast channel can never cause a slower channel's
+ // undelivered actions to be skipped on the next reconnect.
+ const priorCursor = this.shared.channelCursors.get(envelope.channel) ?? 0;
+ if (envelope.serverSeq > priorCursor) {
+ this.shared.channelCursors.set(envelope.channel, envelope.serverSeq);
+ }
if (envelope.channel === ROOT_RESOURCE_URI) {
this.shared.rootState = rootReducer(this.shared.rootState, envelope.action as RootAction);
}
@@ -835,14 +884,25 @@ export class HostRuntime {
// MultiHostStateMirror or their own store).
}
- private trackSubscription(uri: URI): void {
+ private trackSubscription(uri: URI, baselineServerSeq?: number): void {
if (!this.shared.subscriptions.includes(uri)) {
this.shared.subscriptions.push(uri);
}
+ // Seed (or raise) the channel's replay cursor from a subscribe
+ // snapshot's `fromSeq` baseline. Never lower an existing cursor —
+ // a re-subscribe shouldn't rewind a channel that's already caught
+ // up further via reconnect/replay.
+ const current = this.shared.channelCursors.get(uri);
+ if (current === undefined) {
+ this.shared.channelCursors.set(uri, baselineServerSeq ?? 0);
+ } else if (baselineServerSeq !== undefined && baselineServerSeq > current) {
+ this.shared.channelCursors.set(uri, baselineServerSeq);
+ }
}
private untrackSubscription(uri: URI): void {
this.shared.subscriptions = this.shared.subscriptions.filter(u => u !== uri);
+ this.shared.channelCursors.delete(uri);
}
private transitionTo(state: HostState, lastError: Error | null): void {
diff --git a/clients/typescript/test/hosts.test.ts b/clients/typescript/test/hosts.test.ts
index 4e0074dc9..a24bb0c95 100644
--- a/clients/typescript/test/hosts.test.ts
+++ b/clients/typescript/test/hosts.test.ts
@@ -25,10 +25,10 @@ import type {
} from '../src/types/common/messages.js';
import type { SessionSummary } from '../src/types/channels-session/state.js';
import type { AgentInfo } from '../src/types/channels-root/state.js';
-import type { InitializeResult } from '../src/types/common/commands.js';
+import type { InitializeResult, ChannelRecovery } from '../src/types/common/commands.js';
import type { ListSessionsResult } from '../src/types/channels-root/commands.js';
-import { ReconnectResultType } from '../src/types/common/commands.js';
-import type { ReconnectResult } from '../src/types/common/commands.js';
+import { ChannelRecoveryKind } from '../src/types/common/commands.js';
+import type { ReconnectResult, ReconnectParams } from '../src/types/common/commands.js';
import {
ClientIdStoreError,
@@ -68,10 +68,21 @@ interface FakeHostState {
sessions: SessionSummary[];
/** Optional callback invoked once after the first request is handled (init or reconnect). */
injectAfterInit?: (server: AhpTransport) => void | Promise;
+ /**
+ * Optional per-channel-cursor reconnect responder, overriding the
+ * default empty-replay-per-channel behaviour. Every `reconnect` call
+ * is also recorded (in request order) into {@link reconnectRequests}
+ * so tests can assert on the exact per-channel cursors sent.
+ */
+ handleReconnect?: (params: ReconnectParams) => ReconnectResult;
+ /** Snapshot to return from `subscribe` for a given channel, if any (stateless channels get `{}`). */
+ subscribeSnapshots?: Record;
+ /** Populated by {@link handleRequest} with every `reconnect` request's params, in order. */
+ reconnectRequests: ReconnectParams[];
}
function makeFakeState(overrides: Partial = {}): FakeHostState {
- return { agents: [], sessions: [], ...overrides };
+ return { agents: [], sessions: [], reconnectRequests: [], ...overrides };
}
function makeAgent(provider = 'copilot'): AgentInfo {
@@ -94,6 +105,24 @@ function makeSummary(resource: string, title: string, modifiedAt: number): Sessi
};
}
+function makeCanvas(instanceId: string): CanvasState {
+ return { instanceId, extensionId: 'fake', canvasId: 'fake-canvas' };
+}
+
+/** Build an `action` notification envelope for a non-root channel, for reconnect/cursor tests. */
+function makeActionNotif(channel: string, serverSeq: number): JsonRpcNotification {
+ return {
+ jsonrpc: '2.0',
+ method: 'action',
+ params: {
+ channel,
+ action: { type: ActionType.CanvasStateChanged, canvas: makeCanvas(channel) },
+ serverSeq,
+ origin: undefined,
+ },
+ };
+}
+
function buildInitResult(state: FakeHostState): InitializeResult {
return {
protocolVersion: PROTOCOL_VERSION,
@@ -161,15 +190,27 @@ function handleRequest(req: JsonRpcRequest, state: FakeHostState): unknown {
switch (req.method) {
case 'initialize':
return buildInitResult(state);
- case 'reconnect':
- // Default: reply with an empty replay so the supervisor moves on.
- return { type: ReconnectResultType.Replay, actions: [], missing: [] } satisfies ReconnectResult;
+ case 'reconnect': {
+ const params = req.params as ReconnectParams;
+ state.reconnectRequests.push(params);
+ if (state.handleReconnect) return state.handleReconnect(params);
+ // Default: reply with an empty replay for every requested channel
+ // so the supervisor moves on.
+ const channels: ChannelRecovery[] = params.subscriptions.map(sub => ({
+ kind: ChannelRecoveryKind.Replay,
+ channel: sub.channel,
+ actions: [],
+ }));
+ return { channels } satisfies ReconnectResult;
+ }
case 'listSessions':
return buildListResult(state);
case 'subscribe': {
// Minimal `SubscribeResult` — snapshot omitted (stateless channels
// are valid). The fake server doesn't enforce real subscriptions.
- return {};
+ const params = req.params as { channel: string };
+ const snapshot = state.subscribeSnapshots?.[params.channel];
+ return snapshot ? { snapshot } : {};
}
default:
return {};
@@ -1158,5 +1199,173 @@ test('repeated reconnect cycles do not accumulate abort listeners on the shutdow
);
});
+// ─── Per-channel reconnect/replay cursors ───────────────────────────────────
+
+test('reconnect sends independent per-channel cursors — a fast channel never advances a slower one', async () => {
+ const A = 'ahp-canvas:/a';
+ const B = 'ahp-canvas:/b';
+ const state: FakeHostState = makeFakeState({
+ injectAfterInit: async server => {
+ // B races ahead to serverSeq=101 while A never receives anything.
+ // A single connection-wide watermark would send `lastSeenServerSeq:
+ // 101` on the next reconnect and silently skip any of A's
+ // undelivered actions below that value; per-channel cursors must
+ // keep A at its own baseline instead.
+ await new Promise(r => setTimeout(r, 10));
+ try {
+ await server.send(makeActionNotif(B, 101));
+ } catch {
+ // best-effort
+ }
+ },
+ });
+
+ const multi = new MultiHostClient();
+ try {
+ await multi.addHost({
+ id: 'divergent',
+ label: 'divergent',
+ reconnectPolicy: immediateForeverPolicy(),
+ transportFactory: makeBasicFactory(state),
+ });
+ await waitUntil(() => multi.host('divergent')?.state.status === 'connected');
+ await multi.subscribe('divergent', A);
+ await multi.subscribe('divergent', B);
+ await waitUntil(() => multi.host('divergent')?.serverSeq === 101);
+
+ await multi.reconnectHost('divergent');
+ await waitUntil(() => state.reconnectRequests.length >= 1);
+
+ const req = state.reconnectRequests[0];
+ assert.ok(req);
+ const cursorFor = (ch: string): number | undefined =>
+ req.subscriptions.find(s => s.channel === ch)?.lastSeenServerSeq;
+ assert.equal(cursorFor(B), 101, "B's cursor should reflect its own progress");
+ assert.equal(cursorFor(A), 0, "A's cursor must stay at its own baseline — never advanced by B");
+ } finally {
+ await multi.shutdown();
+ }
+});
+
+test('channel replay recovery advances only that channel\'s cursor, independently of other channels', async () => {
+ const A = 'ahp-canvas:/a';
+ const B = 'ahp-canvas:/b';
+ const state: FakeHostState = makeFakeState({
+ handleReconnect: params => ({
+ channels: params.subscriptions.map(sub =>
+ sub.channel === A
+ ? { kind: ChannelRecoveryKind.Replay, channel: A, actions: [
+ { channel: A, action: { type: ActionType.CanvasStateChanged, canvas: makeCanvas('a') }, serverSeq: 10, origin: undefined },
+ { channel: A, action: { type: ActionType.CanvasStateChanged, canvas: makeCanvas('a') }, serverSeq: 11, origin: undefined },
+ ] }
+ : { kind: ChannelRecoveryKind.Replay, channel: sub.channel, actions: [] },
+ ),
+ }),
+ });
+
+ const multi = new MultiHostClient();
+ try {
+ await multi.addHost({
+ id: 'replay',
+ label: 'replay',
+ reconnectPolicy: immediateForeverPolicy(),
+ transportFactory: makeBasicFactory(state),
+ });
+ await waitUntil(() => multi.host('replay')?.state.status === 'connected');
+ await multi.subscribe('replay', A);
+ await multi.subscribe('replay', B);
+
+ await multi.reconnectHost('replay');
+ await waitUntil(() => state.reconnectRequests.length >= 1);
+
+ // The *next* reconnect after the replay must report A's advanced
+ // cursor (11) while B — which had an empty replay — stays at 0.
+ await multi.reconnectHost('replay');
+ await waitUntil(() => state.reconnectRequests.length >= 2);
+ const req = state.reconnectRequests[state.reconnectRequests.length - 1];
+ assert.ok(req);
+ const cursorFor = (ch: string): number | undefined =>
+ req.subscriptions.find(s => s.channel === ch)?.lastSeenServerSeq;
+ assert.equal(cursorFor(A), 11, "A's cursor should reflect the exhausted replay");
+ assert.equal(cursorFor(B), 0, "B's cursor must be untouched by A's replay");
+ } finally {
+ await multi.shutdown();
+ }
+});
+
+test('channel snapshot recovery sets that channel\'s cursor from the snapshot fromSeq baseline', async () => {
+ const A = 'ahp-canvas:/a';
+ const state: FakeHostState = makeFakeState({
+ handleReconnect: params => ({
+ channels: params.subscriptions.map(sub => ({
+ kind: ChannelRecoveryKind.Snapshot,
+ channel: sub.channel,
+ snapshot: { resource: sub.channel, state: makeCanvas('a'), fromSeq: 50 },
+ })),
+ }),
+ });
+
+ const multi = new MultiHostClient();
+ try {
+ await multi.addHost({
+ id: 'snap',
+ label: 'snap',
+ reconnectPolicy: immediateForeverPolicy(),
+ transportFactory: makeBasicFactory(state),
+ });
+ await waitUntil(() => multi.host('snap')?.state.status === 'connected');
+ await multi.subscribe('snap', A);
+
+ await multi.reconnectHost('snap');
+ await waitUntil(() => state.reconnectRequests.length >= 1);
+ await multi.reconnectHost('snap');
+ await waitUntil(() => state.reconnectRequests.length >= 2);
+
+ const req = state.reconnectRequests[state.reconnectRequests.length - 1];
+ assert.equal(req?.subscriptions.find(s => s.channel === A)?.lastSeenServerSeq, 50);
+ } finally {
+ await multi.shutdown();
+ }
+});
+
+test('a channel reported missing on reconnect is dropped from subscriptions and not re-requested', async () => {
+ const A = 'ahp-canvas:/a';
+ const Gone = 'ahp-canvas:/gone';
+ const state: FakeHostState = makeFakeState({
+ handleReconnect: params => ({
+ channels: params.subscriptions.map(sub =>
+ sub.channel === Gone
+ ? { kind: ChannelRecoveryKind.Missing, channel: Gone }
+ : { kind: ChannelRecoveryKind.Replay, channel: sub.channel, actions: [] },
+ ),
+ }),
+ });
+
+ const multi = new MultiHostClient();
+ try {
+ await multi.addHost({
+ id: 'missing',
+ label: 'missing',
+ reconnectPolicy: immediateForeverPolicy(),
+ transportFactory: makeBasicFactory(state),
+ });
+ await waitUntil(() => multi.host('missing')?.state.status === 'connected');
+ await multi.subscribe('missing', A);
+ await multi.subscribe('missing', Gone);
+
+ await multi.reconnectHost('missing');
+ await waitUntil(() => state.reconnectRequests.length >= 1);
+ await waitUntil(() => !(multi.host('missing')?.subscriptions.includes(Gone) ?? true));
+ assert.ok(multi.host('missing')?.subscriptions.includes(A), 'A should remain subscribed');
+
+ await multi.reconnectHost('missing');
+ await waitUntil(() => state.reconnectRequests.length >= 2);
+ const req = state.reconnectRequests[state.reconnectRequests.length - 1];
+ assert.ok(!req?.subscriptions.some(s => s.channel === Gone), 'missing channel must not be re-requested');
+ } finally {
+ await multi.shutdown();
+ }
+});
+
// Reference the imported HostId type to avoid 'unused' warnings.
void (null as unknown as HostId);
diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts
index f5736a2be..b6776bd9d 100644
--- a/clients/typescript/test/types-round-trip.test.ts
+++ b/clients/typescript/test/types-round-trip.test.ts
@@ -59,7 +59,12 @@ import type {
SessionSummary,
} from '../src/types/channels-session/state.js';
import type { SessionAddedParams } from '../src/types/channels-root/notifications.js';
-import type { Implementation, InitializeResult } from '../src/types/common/commands.js';
+import type {
+ Implementation,
+ InitializeResult,
+ ReconnectParams,
+ ReconnectResult,
+} from '../src/types/common/commands.js';
import type { ChatSource } from '../src/types/channels-chat/commands.js';
// ─── Fixture directory ───────────────────────────────────────────────────────
@@ -243,6 +248,8 @@ function bindToType(file: string, type: string, parsed: unknown): void {
case 'PartialSessionSummary': void (parsed as Partial); break;
case 'Implementation': void (parsed as Implementation); break;
case 'InitializeResult': void (parsed as InitializeResult); break;
+ case 'ReconnectParams': void (parsed as ReconnectParams); break;
+ case 'ReconnectResult': void (parsed as ReconnectResult); break;
case 'ChatSource': void (parsed as ChatSource); break;
case 'Snapshot': void (parsed as Snapshot); break;
default:
diff --git a/docs/.changes/20261002-per-channel-replay-cursors.json b/docs/.changes/20261002-per-channel-replay-cursors.json
new file mode 100644
index 000000000..c3c456e19
--- /dev/null
+++ b/docs/.changes/20261002-per-channel-replay-cursors.json
@@ -0,0 +1,4 @@
+{
+ "type": "changed",
+ "message": "`reconnect` now recovers each subscribed channel independently via `ReconnectParams.subscriptions: ChannelReplayCursor[]` and `ReconnectResult.channels: ChannelRecovery[]` (replay/snapshot/missing per channel), replacing the single connection-wide `lastSeenServerSeq` watermark that could let a fast channel's progress silently skip a slower channel's undelivered actions."
+}
diff --git a/docs/guide/ahp-and-acp.md b/docs/guide/ahp-and-acp.md
index b0d5341e7..862d5ce1b 100644
--- a/docs/guide/ahp-and-acp.md
+++ b/docs/guide/ahp-and-acp.md
@@ -37,7 +37,7 @@ The host is the boundary between these two concerns.
| **Multi-client coordination** | Core purpose — N clients see synchronized state | Not addressed — assumes a single client |
| **State authority** | Host holds the authoritative state tree; clients reconcile | Agent holds session state; client receives updates |
| **Action sequencing** | Server-sequenced action envelopes with `serverSeq` ordering | Request-response with streaming notifications |
-| **Reconnection / replay** | Built-in — clients reconnect with `lastSeenServerSeq` and replay missed actions | Not specified at the protocol level |
+| **Reconnection / replay** | Built-in — clients reconnect with per-channel `lastSeenServerSeq` checkpoints and replay missed actions | Not specified at the protocol level |
| **Agent abstraction** | Agent-agnostic by design — clients never see agent-specific details | Agent-specific — defines the agent's interface directly |
| **Session lifecycle** | Host manages sessions; clients subscribe by URI | Client creates sessions directly with the agent |
| **Streaming** | Actions (`chat/delta`, `chat/responsePart`) applied through reducers | `session/update` notifications sent over the wire |
diff --git a/docs/guide/reconciliation.md b/docs/guide/reconciliation.md
index 7637ad6de..921eada48 100644
--- a/docs/guide/reconciliation.md
+++ b/docs/guide/reconciliation.md
@@ -72,13 +72,15 @@ If the transport connection drops:
"params": {
"channel": "ahp-root://",
"clientId": "client-1",
- "lastSeenServerSeq": 42,
- "subscriptions": ["ahp-root://", "ahp-session:/"]
+ "subscriptions": [
+ { "channel": "ahp-root://", "lastSeenServerSeq": 42 },
+ { "channel": "ahp-session:/", "lastSeenServerSeq": 40 }
+ ]
}
}
```
-The server MUST include all replayed data in the response. If the gap is within the replay buffer, the response contains missed action envelopes. If the gap exceeds the buffer, the response contains fresh snapshots instead. In both cases, the client resets `confirmedState` accordingly and clears `pendingActions`.
+Each subscribed channel recovers independently from its own `lastSeenServerSeq` checkpoint — never a single connection-wide watermark, which could let a fast-moving channel's progress mask a slower channel's undelivered action. The server MUST include all replayed and snapshotted data in the response, with one recovery outcome per subscribed channel: a `"replay"` outcome for channels within the replay buffer, a `"snapshot"` outcome for channels whose gap exceeds it, or a `"missing"` outcome for channels the server can no longer resume. The client resets `confirmedState` and clears `pendingActions` for each channel according to its own outcome.
Protocol notifications (like `sessionAdded`/`sessionRemoved`) are **not** replayed — the client should re-fetch the session list.
diff --git a/docs/specification/lifecycle.md b/docs/specification/lifecycle.md
index a2dd20cc3..7fa1f52d3 100644
--- a/docs/specification/lifecycle.md
+++ b/docs/specification/lifecycle.md
@@ -90,7 +90,7 @@ See [Authentication](/specification/authentication) for the full specification.
## Reconnection
-If the transport connection drops, the client reconnects and sends a `reconnect` **request**:
+If the transport connection drops, the client reconnects and sends a `reconnect` **request** carrying one replay checkpoint per subscribed channel, instead of a single connection-wide watermark:
```json
{
@@ -100,48 +100,50 @@ If the transport connection drops, the client reconnects and sends a `reconnect`
"params": {
"channel": "ahp-root://",
"clientId": "client-abc",
- "lastSeenServerSeq": 42,
- "subscriptions": ["ahp-root://", "ahp-session:/"]
- }
-}
-```
-
-The server MUST include all replayed data in the response before returning. If the server can replay from the requested sequence, it returns the missed action envelopes:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 2,
- "result": {
- "type": "replay",
- "actions": [
- { "channel": "ahp-chat:/", "action": { "type": "chat/delta", ... }, "serverSeq": 43 },
- { "channel": "ahp-chat:/", "action": { "type": "chat/delta", ... }, "serverSeq": 44 }
- ],
- "missing": ["ahp-session:/"]
+ "subscriptions": [
+ { "channel": "ahp-root://", "lastSeenServerSeq": 42 },
+ { "channel": "ahp-session:/", "lastSeenServerSeq": 40 }
+ ]
}
}
```
-The `missing` array lists subscriptions from the request that the server cannot resume — for example, sessions or terminals that have been disposed, or resources the client is no longer permitted to observe. Clients SHOULD drop these from their local subscription set.
+Each `ChannelReplayCursor.lastSeenServerSeq` is the `serverSeq` the client last fully applied (or the `fromSeq` of the snapshot it last used to initialize that channel) — **never** a connection-wide value. A single shared watermark lets a fast-moving channel's `serverSeq` silently race ahead of a slower channel's: if channel A has an undelivered action at `serverSeq=100` and channel B goes on to deliver `serverSeq=101`, a connection-wide `lastSeenServerSeq=101` would skip A's action entirely on replay. Tracking one checkpoint per channel prevents that cross-channel skip without claiming any ordering *between* channels. A channel with no baseline yet (e.g. it subscribed but never received its initial snapshot before the connection dropped) sends `0`.
-If the gap exceeds the replay buffer, the server sends fresh snapshots instead:
+The server MUST include all replayed and snapshotted data in the response before returning, and MUST include exactly one recovery outcome per requested subscription — some channels may replay, others may receive a fresh snapshot, and others may be reported missing, all in the same response:
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
- "type": "snapshot",
- "snapshots": [
- { "resource": "ahp-root://", "state": { ... }, "fromSeq": 50 },
- { "resource": "ahp-session:/", "state": { ... }, "fromSeq": 50 }
+ "channels": [
+ {
+ "kind": "replay",
+ "channel": "ahp-chat:/",
+ "actions": [
+ { "channel": "ahp-chat:/", "action": { "type": "chat/delta", ... }, "serverSeq": 43 },
+ { "channel": "ahp-chat:/", "action": { "type": "chat/delta", ... }, "serverSeq": 44 }
+ ]
+ },
+ {
+ "kind": "snapshot",
+ "channel": "ahp-session:/",
+ "snapshot": { "resource": "ahp-session:/", "state": { ... }, "fromSeq": 50 }
+ },
+ { "kind": "missing", "channel": "ahp-session:/" }
]
}
}
```
-Protocol notifications are **not** replayed — the client SHOULD re-fetch the session list via [`listSessions`](/reference/root#listsessions). Stateless channels are simply re-subscribed; missed messages are dropped.
+- `kind: "replay"` means the server included every action the channel missed since the requested `lastSeenServerSeq`, in ascending `serverSeq` order. Stateless channels with nothing to snapshot always use this outcome, with an empty `actions` list when there is nothing to replay.
+- `kind: "snapshot"` means the gap for that channel exceeded its replay buffer; the client MUST discard its prior state for that channel and re-initialize from the included snapshot, whose `fromSeq` becomes the channel's new baseline.
+- `kind: "missing"` means the server can no longer resume that channel — for example, a session or terminal that has been disposed, or a resource the client is no longer permitted to observe. Clients SHOULD drop these channels from their local subscription set.
+
+Because each channel's outcome is independent, one reconnect response may mix all three kinds, and the client MUST apply each channel's recovery using only that channel's own cursor — never another channel's progress, and never the connection's global `serverSeq` identity (see `InitializeResult.serverSeq` in the [`initialize`](/reference/common#initialize) reference).
+
+Protocol notifications are **not** replayed — the client SHOULD re-fetch the session list via [`listSessions`](/reference/root#listsessions).
## Unexpected Disconnection
diff --git a/schema/commands.schema.json b/schema/commands.schema.json
index a3750b147..1d9d698f1 100644
--- a/schema/commands.schema.json
+++ b/schema/commands.schema.json
@@ -260,92 +260,138 @@
"channel"
]
},
- "ReconnectParams": {
+ "ChannelReplayCursor": {
"type": "object",
- "description": "Re-establishes a dropped connection. The server replays missed actions or\nprovides fresh snapshots.",
+ "description": "A single subscribed channel's replay checkpoint, carried in\n`ReconnectParams.subscriptions`.\n\nEach subscription recovers independently from its own `lastSeenServerSeq`\ninstead of one connection-wide watermark. A single shared watermark lets a\nfast-moving channel's `serverSeq` silently race ahead of a slower\nchannel's — if channel A has an undelivered action at `serverSeq=100` and\nchannel B goes on to deliver `serverSeq=101`, a connection-wide\n`lastSeenServerSeq=101` would skip A's action entirely on replay. Tracking\none checkpoint per channel prevents that cross-channel skip without\nclaiming any ordering *between* channels.",
"properties": {
"channel": {
- "type": "string",
- "enum": [
- "ahp-root://"
- ]
- },
- "_meta": {
- "type": "object",
- "additionalProperties": {},
- "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand."
- },
- "clientId": {
- "type": "string",
- "description": "Client identifier from the original connection"
+ "$ref": "#/$defs/URI",
+ "description": "The subscribed channel URI."
},
"lastSeenServerSeq": {
"type": "number",
- "description": "Last `serverSeq` the client received"
- },
- "subscriptions": {
- "type": "array",
- "items": {
- "$ref": "#/$defs/URI"
- },
- "description": "URIs the client was subscribed to"
+ "description": "`serverSeq` of the last action the client fully applied or safely\nretained for `channel`, or the `fromSeq` of the `Snapshot` the client\nlast used to initialize `channel` (see {@link Snapshot.fromSeq}).\n\n`0` means the client has no baseline for `channel` yet — e.g. it\nsubscribed but the `subscribe`/`initialize` response snapshot (if any)\nnever arrived before the connection dropped. The server MUST NOT use\nanother channel's progress to advance this checkpoint, and MUST NOT\nuse it to seed the connection's global `serverSeq` identity (see\n{@link InitializeResult.serverSeq})."
}
},
"required": [
"channel",
- "clientId",
- "lastSeenServerSeq",
- "subscriptions"
+ "lastSeenServerSeq"
]
},
- "ReconnectReplayResult": {
+ "ChannelReplayRecovery": {
"type": "object",
- "description": "Reconnect result when the server can replay from the requested sequence.\n\nThe server MUST include all replayed data in the response.",
+ "description": "Recovery outcome for a channel that replayed cleanly.\n\nThe server MUST include every action the channel missed since the\nmatching `ChannelReplayCursor.lastSeenServerSeq`, in ascending `serverSeq`\norder, and MUST only include actions whose `ActionEnvelope.channel`\nequals `channel`.",
"properties": {
- "type": {
+ "kind": {
"const": "replay",
"description": "Discriminant"
},
+ "channel": {
+ "$ref": "#/$defs/URI",
+ "description": "The channel this recovery applies to."
+ },
"actions": {
"type": "array",
"items": {
"$ref": "#/$defs/ActionEnvelope"
},
- "description": "Missed action envelopes since `lastSeenServerSeq`"
+ "description": "Missed action envelopes since the requested `lastSeenServerSeq`."
+ }
+ },
+ "required": [
+ "kind",
+ "channel",
+ "actions"
+ ]
+ },
+ "ChannelSnapshotRecovery": {
+ "type": "object",
+ "description": "Recovery outcome for a channel whose gap exceeded its replay buffer.\n\nAbsent for stateless channels that have no state to snapshot; the server\nMUST instead use {@link ChannelReplayRecovery} with an empty `actions`\nlist (or {@link ChannelMissingRecovery}, if the channel itself no longer\nexists) for those.",
+ "properties": {
+ "kind": {
+ "const": "snapshot",
+ "description": "Discriminant"
+ },
+ "channel": {
+ "$ref": "#/$defs/URI",
+ "description": "The channel this recovery applies to."
+ },
+ "snapshot": {
+ "$ref": "#/$defs/Snapshot",
+ "description": "Fresh snapshot the client MUST use as its new baseline for this channel."
+ }
+ },
+ "required": [
+ "kind",
+ "channel",
+ "snapshot"
+ ]
+ },
+ "ChannelMissingRecovery": {
+ "type": "object",
+ "description": "Recovery outcome for a channel the server cannot resume — e.g. a disposed\nsession or terminal, or a resource the client is no longer permitted to\nobserve. Clients SHOULD drop `channel` from their local subscription set.",
+ "properties": {
+ "kind": {
+ "const": "missing",
+ "description": "Discriminant"
+ },
+ "channel": {
+ "$ref": "#/$defs/URI",
+ "description": "The channel this recovery applies to."
+ }
+ },
+ "required": [
+ "kind",
+ "channel"
+ ]
+ },
+ "ReconnectParams": {
+ "type": "object",
+ "description": "Re-establishes a dropped connection. The server recovers each subscribed\nchannel independently — some channels may replay, others may receive a\nfresh snapshot, and others may be reported missing, all in the same\nresponse (see {@link ChannelRecovery}).",
+ "properties": {
+ "channel": {
+ "type": "string",
+ "enum": [
+ "ahp-root://"
+ ]
},
- "missing": {
+ "_meta": {
+ "type": "object",
+ "additionalProperties": {},
+ "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand."
+ },
+ "clientId": {
+ "type": "string",
+ "description": "Client identifier from the original connection"
+ },
+ "subscriptions": {
"type": "array",
"items": {
- "$ref": "#/$defs/URI"
+ "$ref": "#/$defs/ChannelReplayCursor"
},
- "description": "URIs from `ReconnectParams.subscriptions` that the server cannot resume.\nThis includes resources that no longer exist (e.g. disposed sessions or\nterminals) as well as resources the client is no longer permitted to\nobserve. Clients SHOULD drop these from their local subscription set."
+ "description": "Per-channel replay checkpoints for every channel the client is still subscribed to."
}
},
"required": [
- "type",
- "actions",
- "missing"
+ "channel",
+ "clientId",
+ "subscriptions"
]
},
- "ReconnectSnapshotResult": {
+ "ReconnectResult": {
"type": "object",
- "description": "Reconnect result when the gap exceeds the replay buffer.",
+ "description": "Result of the `reconnect` command.\n\nThe server MUST include all replayed and snapshotted data in the response\nbefore returning, and MUST include exactly one {@link ChannelRecovery} per\nchannel named in `ReconnectParams.subscriptions`.",
"properties": {
- "type": {
- "const": "snapshot",
- "description": "Discriminant"
- },
- "snapshots": {
+ "channels": {
"type": "array",
"items": {
- "$ref": "#/$defs/Snapshot"
+ "$ref": "#/$defs/ChannelRecovery"
},
- "description": "Fresh snapshots for each subscription"
+ "description": "One recovery outcome per requested subscription, in any order."
}
},
"required": [
- "type",
- "snapshots"
+ "channels"
]
},
"SubscribeParams": {
@@ -1745,16 +1791,19 @@
"description": "Empty acknowledgement; the updated automation state is delivered by action.",
"properties": {}
},
- "ReconnectResult": {
+ "ChannelRecovery": {
"oneOf": [
{
- "$ref": "#/$defs/ReconnectReplayResult"
+ "$ref": "#/$defs/ChannelReplayRecovery"
+ },
+ {
+ "$ref": "#/$defs/ChannelSnapshotRecovery"
},
{
- "$ref": "#/$defs/ReconnectSnapshotResult"
+ "$ref": "#/$defs/ChannelMissingRecovery"
}
],
- "description": "Result of the `reconnect` command."
+ "description": "Per-channel reconnect recovery outcome."
},
"ChatSource": {
"oneOf": [
diff --git a/schema/errors.schema.json b/schema/errors.schema.json
index fb08af031..a4a949f0a 100644
--- a/schema/errors.schema.json
+++ b/schema/errors.schema.json
@@ -6240,92 +6240,138 @@
"channel"
]
},
- "ReconnectParams": {
+ "ChannelReplayCursor": {
"type": "object",
- "description": "Re-establishes a dropped connection. The server replays missed actions or\nprovides fresh snapshots.",
+ "description": "A single subscribed channel's replay checkpoint, carried in\n`ReconnectParams.subscriptions`.\n\nEach subscription recovers independently from its own `lastSeenServerSeq`\ninstead of one connection-wide watermark. A single shared watermark lets a\nfast-moving channel's `serverSeq` silently race ahead of a slower\nchannel's — if channel A has an undelivered action at `serverSeq=100` and\nchannel B goes on to deliver `serverSeq=101`, a connection-wide\n`lastSeenServerSeq=101` would skip A's action entirely on replay. Tracking\none checkpoint per channel prevents that cross-channel skip without\nclaiming any ordering *between* channels.",
"properties": {
"channel": {
- "type": "string",
- "enum": [
- "ahp-root://"
- ]
- },
- "_meta": {
- "type": "object",
- "additionalProperties": {},
- "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand."
- },
- "clientId": {
- "type": "string",
- "description": "Client identifier from the original connection"
+ "$ref": "#/$defs/URI",
+ "description": "The subscribed channel URI."
},
"lastSeenServerSeq": {
"type": "number",
- "description": "Last `serverSeq` the client received"
- },
- "subscriptions": {
- "type": "array",
- "items": {
- "$ref": "#/$defs/URI"
- },
- "description": "URIs the client was subscribed to"
+ "description": "`serverSeq` of the last action the client fully applied or safely\nretained for `channel`, or the `fromSeq` of the `Snapshot` the client\nlast used to initialize `channel` (see {@link Snapshot.fromSeq}).\n\n`0` means the client has no baseline for `channel` yet — e.g. it\nsubscribed but the `subscribe`/`initialize` response snapshot (if any)\nnever arrived before the connection dropped. The server MUST NOT use\nanother channel's progress to advance this checkpoint, and MUST NOT\nuse it to seed the connection's global `serverSeq` identity (see\n{@link InitializeResult.serverSeq})."
}
},
"required": [
"channel",
- "clientId",
- "lastSeenServerSeq",
- "subscriptions"
+ "lastSeenServerSeq"
]
},
- "ReconnectReplayResult": {
+ "ChannelReplayRecovery": {
"type": "object",
- "description": "Reconnect result when the server can replay from the requested sequence.\n\nThe server MUST include all replayed data in the response.",
+ "description": "Recovery outcome for a channel that replayed cleanly.\n\nThe server MUST include every action the channel missed since the\nmatching `ChannelReplayCursor.lastSeenServerSeq`, in ascending `serverSeq`\norder, and MUST only include actions whose `ActionEnvelope.channel`\nequals `channel`.",
"properties": {
- "type": {
+ "kind": {
"const": "replay",
"description": "Discriminant"
},
+ "channel": {
+ "$ref": "#/$defs/URI",
+ "description": "The channel this recovery applies to."
+ },
"actions": {
"type": "array",
"items": {
"$ref": "#/$defs/ActionEnvelope"
},
- "description": "Missed action envelopes since `lastSeenServerSeq`"
+ "description": "Missed action envelopes since the requested `lastSeenServerSeq`."
+ }
+ },
+ "required": [
+ "kind",
+ "channel",
+ "actions"
+ ]
+ },
+ "ChannelSnapshotRecovery": {
+ "type": "object",
+ "description": "Recovery outcome for a channel whose gap exceeded its replay buffer.\n\nAbsent for stateless channels that have no state to snapshot; the server\nMUST instead use {@link ChannelReplayRecovery} with an empty `actions`\nlist (or {@link ChannelMissingRecovery}, if the channel itself no longer\nexists) for those.",
+ "properties": {
+ "kind": {
+ "const": "snapshot",
+ "description": "Discriminant"
},
- "missing": {
+ "channel": {
+ "$ref": "#/$defs/URI",
+ "description": "The channel this recovery applies to."
+ },
+ "snapshot": {
+ "$ref": "#/$defs/Snapshot",
+ "description": "Fresh snapshot the client MUST use as its new baseline for this channel."
+ }
+ },
+ "required": [
+ "kind",
+ "channel",
+ "snapshot"
+ ]
+ },
+ "ChannelMissingRecovery": {
+ "type": "object",
+ "description": "Recovery outcome for a channel the server cannot resume — e.g. a disposed\nsession or terminal, or a resource the client is no longer permitted to\nobserve. Clients SHOULD drop `channel` from their local subscription set.",
+ "properties": {
+ "kind": {
+ "const": "missing",
+ "description": "Discriminant"
+ },
+ "channel": {
+ "$ref": "#/$defs/URI",
+ "description": "The channel this recovery applies to."
+ }
+ },
+ "required": [
+ "kind",
+ "channel"
+ ]
+ },
+ "ReconnectParams": {
+ "type": "object",
+ "description": "Re-establishes a dropped connection. The server recovers each subscribed\nchannel independently — some channels may replay, others may receive a\nfresh snapshot, and others may be reported missing, all in the same\nresponse (see {@link ChannelRecovery}).",
+ "properties": {
+ "channel": {
+ "type": "string",
+ "enum": [
+ "ahp-root://"
+ ]
+ },
+ "_meta": {
+ "type": "object",
+ "additionalProperties": {},
+ "description": "Optional JSON-serializable metadata associated with this request.\nReceivers MUST ignore keys they do not understand."
+ },
+ "clientId": {
+ "type": "string",
+ "description": "Client identifier from the original connection"
+ },
+ "subscriptions": {
"type": "array",
"items": {
- "$ref": "#/$defs/URI"
+ "$ref": "#/$defs/ChannelReplayCursor"
},
- "description": "URIs from `ReconnectParams.subscriptions` that the server cannot resume.\nThis includes resources that no longer exist (e.g. disposed sessions or\nterminals) as well as resources the client is no longer permitted to\nobserve. Clients SHOULD drop these from their local subscription set."
+ "description": "Per-channel replay checkpoints for every channel the client is still subscribed to."
}
},
"required": [
- "type",
- "actions",
- "missing"
+ "channel",
+ "clientId",
+ "subscriptions"
]
},
- "ReconnectSnapshotResult": {
+ "ReconnectResult": {
"type": "object",
- "description": "Reconnect result when the gap exceeds the replay buffer.",
+ "description": "Result of the `reconnect` command.\n\nThe server MUST include all replayed and snapshotted data in the response\nbefore returning, and MUST include exactly one {@link ChannelRecovery} per\nchannel named in `ReconnectParams.subscriptions`.",
"properties": {
- "type": {
- "const": "snapshot",
- "description": "Discriminant"
- },
- "snapshots": {
+ "channels": {
"type": "array",
"items": {
- "$ref": "#/$defs/Snapshot"
+ "$ref": "#/$defs/ChannelRecovery"
},
- "description": "Fresh snapshots for each subscription"
+ "description": "One recovery outcome per requested subscription, in any order."
}
},
"required": [
- "type",
- "snapshots"
+ "channels"
]
},
"SubscribeParams": {
@@ -8488,6 +8534,20 @@
"serverSeq"
]
},
+ "ChannelRecovery": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/ChannelReplayRecovery"
+ },
+ {
+ "$ref": "#/$defs/ChannelSnapshotRecovery"
+ },
+ {
+ "$ref": "#/$defs/ChannelMissingRecovery"
+ }
+ ],
+ "description": "Per-channel reconnect recovery outcome."
+ },
"StateAction": {
"oneOf": [
{
diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts
index bcc6f595a..5c8506503 100644
--- a/scripts/generate-csharp.ts
+++ b/scripts/generate-csharp.ts
@@ -2268,7 +2268,7 @@ function generateActionsFile(project: Project): string {
// ─── Commands File Generator ─────────────────────────────────────────────────
-const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
+const COMMAND_ENUMS = ['ChannelRecoveryKind', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: string }[] = [
{ name: 'InitializeParams' }, { name: 'InitializeResult' },
@@ -2283,12 +2283,15 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str
{ name: 'AutomationRunCancellationCapability' },
{ name: 'AutomationCustomizationsCapability' },
{ name: 'ReconnectParams' },
- // Union variants MUST self-carry their `type` discriminator: UnionConverter.Write
+ { name: 'ChannelReplayCursor' },
+ { name: 'ReconnectResult' },
+ // Union variants MUST self-carry their `kind` discriminator: UnionConverter.Write
// serializes the inner value by its runtime type and relies on that property to
// emit the discriminator (matching ACTION_VARIANTS' includeDiscriminants). Omitting
- // it silently drops `type` on write, breaking the reconnect-result round-trip.
- { name: 'ReconnectReplayResult' },
- { name: 'ReconnectSnapshotResult' },
+ // it silently drops `kind` on write, breaking the channel-recovery round-trip.
+ { name: 'ChannelReplayRecovery' },
+ { name: 'ChannelSnapshotRecovery' },
+ { name: 'ChannelMissingRecovery' },
{ name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' },
{ name: 'SessionForkSource' }, { name: 'CreateSessionParams' },
{ name: 'DisposeSessionParams' },
@@ -2346,13 +2349,14 @@ const CHAT_MOVE_DESTINATION_UNION: UnionConfig = {
],
};
-const RECONNECT_RESULT_UNION: UnionConfig = {
- name: 'ReconnectResult',
- discriminantField: 'type',
- doc: 'ReconnectResult is the result of the `reconnect` command.',
+const CHANNEL_RECOVERY_UNION: UnionConfig = {
+ name: 'ChannelRecovery',
+ discriminantField: 'kind',
+ doc: 'ChannelRecovery is the per-channel reconnect recovery outcome.',
variants: [
- { variantName: 'Replay', innerType: 'ReconnectReplayResult', wireValue: 'replay' },
- { variantName: 'Snapshot', innerType: 'ReconnectSnapshotResult', wireValue: 'snapshot' },
+ { variantName: 'Replay', innerType: 'ChannelReplayRecovery', wireValue: 'replay' },
+ { variantName: 'Snapshot', innerType: 'ChannelSnapshotRecovery', wireValue: 'snapshot' },
+ { variantName: 'Missing', innerType: 'ChannelMissingRecovery', wireValue: 'missing' },
],
};
@@ -2446,8 +2450,8 @@ function generateCommandsFile(project: Project): string {
}
}
- lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n');
- lines.push(generateDiscriminatedUnion(project, RECONNECT_RESULT_UNION));
+ lines.push('// ─── ChannelRecovery Union ────────────────────────────────────────────\n');
+ lines.push(generateDiscriminatedUnion(project, CHANNEL_RECOVERY_UNION));
lines.push(generateDiscriminatedUnion(project, CHAT_SOURCE_UNION));
lines.push(generateDiscriminatedUnion(project, CHAT_MOVE_DESTINATION_UNION));
lines.push('');
@@ -2779,7 +2783,7 @@ function checkExhaustiveness(project: Project): void {
'AutomationDisableCondition',
'AutomationRunOrigin', 'AutomationRunLifecycle',
'SessionInputRequest', 'BackgroundWork', 'ToolCallConfirmationState', 'ToolCallRiskAssessment',
- 'ReconnectResult', 'AuthRequiredErrorData',
+ 'ChannelRecovery', 'AuthRequiredErrorData',
'PermissionDeniedErrorData', 'UnsupportedProtocolVersionErrorData',
'AhpError', 'AhpErrorDetailsMap', 'AhpErrorCode', 'AhpErrorCodeWithData',
'JsonRpcErrorCode', 'ChangesetOperationTarget', 'CustomizationEnablement',
diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts
index 10cc2754c..5db7ad2d1 100644
--- a/scripts/generate-go.ts
+++ b/scripts/generate-go.ts
@@ -1771,7 +1771,7 @@ function generateActionsFile(project: Project): string {
// ─── Commands File Generator ─────────────────────────────────────────────────
-const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
+const COMMAND_ENUMS = ['ChannelRecoveryKind', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: string }[] = [
{ name: 'InitializeParams' }, { name: 'InitializeResult' },
@@ -1782,8 +1782,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str
{ name: 'AutomationCustomizationsCapability' },
{ name: 'Implementation' },
{ name: 'ReconnectParams' },
- { name: 'ReconnectReplayResult', omitDiscriminants: true },
- { name: 'ReconnectSnapshotResult', omitDiscriminants: true },
+ { name: 'ChannelReplayCursor' },
+ { name: 'ReconnectResult' },
+ { name: 'ChannelReplayRecovery', omitDiscriminants: true },
+ { name: 'ChannelSnapshotRecovery', omitDiscriminants: true },
+ { name: 'ChannelMissingRecovery', omitDiscriminants: true },
{ name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' },
{ name: 'CreateSessionParams' },
{ name: 'DisposeSessionParams' },
@@ -1816,14 +1819,16 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str
{ name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' },
];
-const RECONNECT_RESULT_UNION: UnionConfig = {
- name: 'ReconnectResult',
- discriminantField: 'type',
- doc: 'ReconnectResult is the result of the `reconnect` command.',
+const CHANNEL_RECOVERY_UNION: UnionConfig = {
+ name: 'ChannelRecovery',
+ discriminantField: 'kind',
+ doc: 'ChannelRecovery is the per-channel reconnect recovery outcome.',
variants: [
- { variantName: 'Replay', innerType: 'ReconnectReplayResult', wireValue: 'replay' },
- { variantName: 'Snapshot', innerType: 'ReconnectSnapshotResult', wireValue: 'snapshot' },
+ { variantName: 'Replay', innerType: 'ChannelReplayRecovery', wireValue: 'replay' },
+ { variantName: 'Snapshot', innerType: 'ChannelSnapshotRecovery', wireValue: 'snapshot' },
+ { variantName: 'Missing', innerType: 'ChannelMissingRecovery', wireValue: 'missing' },
],
+ injectDiscriminantOnMarshal: true,
};
const CHAT_SOURCE_UNION: UnionConfig = {
@@ -2021,8 +2026,8 @@ function generateCommandsFile(project: Project): string {
lines.push('// ─── ChatMoveDestination Union ────────────────────────────────────────\n');
lines.push(generateDiscriminatedUnion(project, CHAT_MOVE_DESTINATION_UNION));
lines.push('');
- lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n');
- lines.push(generateDiscriminatedUnion(project, RECONNECT_RESULT_UNION));
+ lines.push('// ─── ChannelRecovery Union ────────────────────────────────────────────\n');
+ lines.push(generateDiscriminatedUnion(project, CHANNEL_RECOVERY_UNION));
lines.push('');
lines.push('// ─── Changeset Operation Unions ───────────────────────────────────────\n');
@@ -2397,7 +2402,7 @@ function checkExhaustiveness(project: Project): void {
'SessionInputRequest',
'BackgroundWork',
'ToolCallConfirmationState',
- 'ReconnectResult',
+ 'ChannelRecovery',
'SessionOrigin',
'AutomationTrigger',
'AutomationDisableCondition',
diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts
index bc475a6c8..e2c8c6404 100644
--- a/scripts/generate-kotlin.ts
+++ b/scripts/generate-kotlin.ts
@@ -1761,7 +1761,7 @@ function generateActionsFile(project: Project): string {
// ─── Commands File Generator ─────────────────────────────────────────────────
-const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
+const COMMAND_ENUMS = ['ChannelRecoveryKind', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
const COMMAND_STRUCTS = [
'InitializeParams', 'InitializeResult',
@@ -1771,7 +1771,8 @@ const COMMAND_STRUCTS = [
'AutomationRunCancellationCapability',
'AutomationCustomizationsCapability',
'Implementation',
- 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult',
+ 'ReconnectParams', 'ChannelReplayCursor', 'ReconnectResult',
+ 'ChannelReplayRecovery', 'ChannelSnapshotRecovery', 'ChannelMissingRecovery',
'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult',
'CreateSessionParams', 'DisposeSessionParams',
'CreateChatParams', 'DisposeChatParams',
@@ -1803,12 +1804,13 @@ const COMMAND_STRUCTS = [
'FetchAutomationRunsParams', 'FetchAutomationRunsResult',
];
-const RECONNECT_RESULT_UNION: UnionConfig = {
- name: 'ReconnectResult',
- discriminantField: 'type',
+const CHANNEL_RECOVERY_UNION: UnionConfig = {
+ name: 'ChannelRecovery',
+ discriminantField: 'kind',
variants: [
- { caseName: 'Replay', structName: 'ReconnectReplayResult', discriminantValue: 'replay' },
- { caseName: 'Snapshot', structName: 'ReconnectSnapshotResult', discriminantValue: 'snapshot' },
+ { caseName: 'Replay', structName: 'ChannelReplayRecovery', discriminantValue: 'replay' },
+ { caseName: 'Snapshot', structName: 'ChannelSnapshotRecovery', discriminantValue: 'snapshot' },
+ { caseName: 'Missing', structName: 'ChannelMissingRecovery', discriminantValue: 'missing' },
],
};
@@ -2025,9 +2027,9 @@ function generateCommandsFile(project: Project): string {
lines.push(generateDiscriminatedUnion(project, CHAT_MOVE_DESTINATION_UNION));
lines.push('');
- lines.push('// ─── ReconnectResult Union ──────────────────────────────────────────────────');
+ lines.push('// ─── ChannelRecovery Union ──────────────────────────────────────────────────');
lines.push('');
- lines.push(generateDiscriminatedUnion(project, RECONNECT_RESULT_UNION));
+ lines.push(generateDiscriminatedUnion(project, CHANNEL_RECOVERY_UNION));
lines.push('');
lines.push('// ─── Changeset Operation Unions ─────────────────────────────────────────────');
@@ -2422,7 +2424,7 @@ function checkExhaustiveness(project: Project): void {
'AhpErrorCode', // type-level alias over AhpErrorCodes const enum
'AhpErrorCodeWithData', // type-level alias; not a Kotlin type
'JsonRpcErrorCode', // type-level alias over JsonRpcErrorCodes const enum
- 'ReconnectResult', // RECONNECT_RESULT_UNION discriminated union
+ 'ChannelRecovery', // CHANNEL_RECOVERY_UNION discriminated union
'SessionOrigin', // SESSION_ORIGIN_UNION discriminated union
'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union
'AutomationDisableCondition', // AUTOMATION_DISABLE_CONDITION_UNION discriminated union
diff --git a/scripts/generate-markdown.ts b/scripts/generate-markdown.ts
index e2c0b9497..3abdcf417 100644
--- a/scripts/generate-markdown.ts
+++ b/scripts/generate-markdown.ts
@@ -688,16 +688,22 @@ function emitCommandBlock(project: Project, entry: RegistryEntry, paramsIface: I
}
// Result handling
- if (entry.method === 'reconnect') {
- const replay = getInterfaceMaybe(project, 'ReconnectReplayResult');
- const snapshot = getInterfaceMaybe(project, 'ReconnectSnapshotResult');
- if (replay) {
- lines.push('**Result (replay):** When the server can replay from the requested sequence:\n');
- lines.push(renderInterfaceTable(replay) + '\n');
+ if (entry.method === 'reconnect' && entry.resultType) {
+ const resultIface = getInterfaceMaybe(project, entry.resultType.trim());
+ if (resultIface) {
+ lines.push('**Result:**\n');
+ lines.push(renderInterfaceTable(resultIface) + '\n');
}
- if (snapshot) {
- lines.push('**Result (snapshot):** When the gap exceeds the replay buffer:\n');
- lines.push(renderInterfaceTable(snapshot) + '\n');
+ for (const [label, kind] of [
+ ['replay', 'ChannelReplayRecovery'],
+ ['snapshot', 'ChannelSnapshotRecovery'],
+ ['missing', 'ChannelMissingRecovery'],
+ ] as const) {
+ const iface = getInterfaceMaybe(project, kind);
+ if (iface) {
+ lines.push(`**\`ChannelRecovery\` (${label}):**\n`);
+ lines.push(renderInterfaceTable(iface) + '\n');
+ }
}
} else if (entry.resultType) {
const t = entry.resultType.trim();
diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts
index f1d4ce2dd..10a4ee547 100644
--- a/scripts/generate-rust.ts
+++ b/scripts/generate-rust.ts
@@ -1800,7 +1800,7 @@ pub struct ActionEnvelope {
// ─── Commands File Generator ─────────────────────────────────────────────────
-const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
+const COMMAND_ENUMS = ['ChannelRecoveryKind', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: string }[] = [
{ name: 'InitializeParams' }, { name: 'InitializeResult' },
@@ -1811,8 +1811,11 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s
{ name: 'AutomationCustomizationsCapability' },
{ name: 'Implementation' },
{ name: 'ReconnectParams' },
- { name: 'ReconnectReplayResult', omitDiscriminants: true },
- { name: 'ReconnectSnapshotResult', omitDiscriminants: true },
+ { name: 'ChannelReplayCursor' },
+ { name: 'ReconnectResult' },
+ { name: 'ChannelReplayRecovery', omitDiscriminants: true },
+ { name: 'ChannelSnapshotRecovery', omitDiscriminants: true },
+ { name: 'ChannelMissingRecovery', omitDiscriminants: true },
{ name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' },
{ name: 'CreateSessionParams' },
{ name: 'DisposeSessionParams' },
@@ -1847,13 +1850,14 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s
{ name: 'FetchAutomationRunsParams' }, { name: 'FetchAutomationRunsResult' },
];
-const RECONNECT_RESULT_UNION: UnionConfig = {
- name: 'ReconnectResult',
- discriminantField: 'type',
- doc: 'Result of the `reconnect` command.',
+const CHANNEL_RECOVERY_UNION: UnionConfig = {
+ name: 'ChannelRecovery',
+ discriminantField: 'kind',
+ doc: 'Per-channel reconnect recovery outcome.',
variants: [
- { variantName: 'Replay', innerType: 'ReconnectReplayResult', wireValue: 'replay' },
- { variantName: 'Snapshot', innerType: 'ReconnectSnapshotResult', wireValue: 'snapshot' },
+ { variantName: 'Replay', innerType: 'ChannelReplayRecovery', wireValue: 'replay' },
+ { variantName: 'Snapshot', innerType: 'ChannelSnapshotRecovery', wireValue: 'snapshot' },
+ { variantName: 'Missing', innerType: 'ChannelMissingRecovery', wireValue: 'missing' },
],
};
@@ -1920,8 +1924,8 @@ function generateCommandsFile(project: Project): string {
lines.push('// ─── ChatMoveDestination Union ────────────────────────────────────────\n');
lines.push(generateDiscriminatedUnion(project, CHAT_MOVE_DESTINATION_UNION));
lines.push('');
- lines.push('// ─── ReconnectResult Union ────────────────────────────────────────────\n');
- lines.push(generateDiscriminatedUnion(project, RECONNECT_RESULT_UNION));
+ lines.push('// ─── ChannelRecovery Union ────────────────────────────────────────────\n');
+ lines.push(generateDiscriminatedUnion(project, CHANNEL_RECOVERY_UNION));
lines.push('');
lines.push('// ─── Changeset Operation Unions ───────────────────────────────────────\n');
@@ -2348,7 +2352,7 @@ function checkExhaustiveness(project: Project): void {
'SessionInputRequest', // SESSION_INPUT_REQUEST_UNION discriminated union
'BackgroundWork', // BACKGROUND_WORK_UNION discriminated union
'ToolCallConfirmationState', // TOOL_CALL_CONFIRMATION_STATE_UNION discriminated union
- 'ReconnectResult',
+ 'ChannelRecovery',
'SessionOrigin',
'AutomationTrigger',
'AutomationDisableCondition',
diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts
index 51db38837..654c3865b 100644
--- a/scripts/generate-swift.ts
+++ b/scripts/generate-swift.ts
@@ -1670,7 +1670,7 @@ function generateActionsFile(project: Project): string {
// ─── Commands File Generator ─────────────────────────────────────────────────
-const COMMAND_ENUMS = ['ReconnectResultType', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
+const COMMAND_ENUMS = ['ChannelRecoveryKind', 'ChatSourceKind', 'ChatMoveDestinationKind', 'ContentEncoding', 'CompletionItemKind', 'ResourceType', 'ResourceWriteMode'];
const COMMAND_STRUCTS = [
'InitializeParams', 'InitializeResult', 'ClientCapabilities', 'AutomationCapabilities',
@@ -1679,7 +1679,8 @@ const COMMAND_STRUCTS = [
'AutomationRunCancellationCapability',
'AutomationCustomizationsCapability',
'Implementation',
- 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult',
+ 'ReconnectParams', 'ChannelReplayCursor', 'ReconnectResult',
+ 'ChannelReplayRecovery', 'ChannelSnapshotRecovery', 'ChannelMissingRecovery',
'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult',
'CreateSessionParams', 'DisposeSessionParams',
'CreateChatParams', 'DisposeChatParams',
@@ -1711,12 +1712,13 @@ const COMMAND_STRUCTS = [
'FetchAutomationRunsParams', 'FetchAutomationRunsResult',
];
-const RECONNECT_RESULT_UNION: UnionConfig = {
- name: 'ReconnectResult',
- discriminantField: 'type',
+const CHANNEL_RECOVERY_UNION: UnionConfig = {
+ name: 'ChannelRecovery',
+ discriminantField: 'kind',
variants: [
- { caseName: 'replay', structName: 'ReconnectReplayResult', discriminantValue: 'replay' },
- { caseName: 'snapshot', structName: 'ReconnectSnapshotResult', discriminantValue: 'snapshot' },
+ { caseName: 'replay', structName: 'ChannelReplayRecovery', discriminantValue: 'replay' },
+ { caseName: 'snapshot', structName: 'ChannelSnapshotRecovery', discriminantValue: 'snapshot' },
+ { caseName: 'missing', structName: 'ChannelMissingRecovery', discriminantValue: 'missing' },
],
};
@@ -1784,8 +1786,8 @@ function generateCommandsFile(project: Project): string {
lines.push('');
lines.push(generateDiscriminatedUnion(project, CHAT_MOVE_DESTINATION_UNION));
lines.push('');
- lines.push('// MARK: - ReconnectResult Union\n');
- lines.push(generateDiscriminatedUnion(project, RECONNECT_RESULT_UNION));
+ lines.push('// MARK: - ChannelRecovery Union\n');
+ lines.push(generateDiscriminatedUnion(project, CHANNEL_RECOVERY_UNION));
lines.push('');
lines.push('// MARK: - Changeset Operation Unions\n');
@@ -2438,7 +2440,7 @@ function checkExhaustiveness(project: Project): void {
'AhpErrorCode', // type-level alias over AhpErrorCodes const enum
'AhpErrorCodeWithData', // type-level alias; not a Swift type
'JsonRpcErrorCode', // type-level alias over JsonRpcErrorCodes const enum
- 'ReconnectResult', // RECONNECT_RESULT_UNION discriminated union
+ 'ChannelRecovery', // CHANNEL_RECOVERY_UNION discriminated union
'SessionOrigin', // SESSION_ORIGIN_UNION discriminated union
'AutomationTrigger', // AUTOMATION_TRIGGER_UNION discriminated union
'AutomationDisableCondition', // AUTOMATION_DISABLE_CONDITION_UNION discriminated union
diff --git a/types/common/commands.ts b/types/common/commands.ts
index b7adb7c60..ae0b5130b 100644
--- a/types/common/commands.ts
+++ b/types/common/commands.ts
@@ -405,19 +405,113 @@ export interface PingParams extends BaseParams {
// ─── reconnect ───────────────────────────────────────────────────────────────
/**
- * Discriminant for reconnect result types.
+ * A single subscribed channel's replay checkpoint, carried in
+ * `ReconnectParams.subscriptions`.
+ *
+ * Each subscription recovers independently from its own `lastSeenServerSeq`
+ * instead of one connection-wide watermark. A single shared watermark lets a
+ * fast-moving channel's `serverSeq` silently race ahead of a slower
+ * channel's — if channel A has an undelivered action at `serverSeq=100` and
+ * channel B goes on to deliver `serverSeq=101`, a connection-wide
+ * `lastSeenServerSeq=101` would skip A's action entirely on replay. Tracking
+ * one checkpoint per channel prevents that cross-channel skip without
+ * claiming any ordering *between* channels.
+ *
+ * @category Commands
+ */
+export interface ChannelReplayCursor {
+ /** The subscribed channel URI. */
+ channel: URI;
+ /**
+ * `serverSeq` of the last action the client fully applied or safely
+ * retained for `channel`, or the `fromSeq` of the `Snapshot` the client
+ * last used to initialize `channel` (see {@link Snapshot.fromSeq}).
+ *
+ * `0` means the client has no baseline for `channel` yet — e.g. it
+ * subscribed but the `subscribe`/`initialize` response snapshot (if any)
+ * never arrived before the connection dropped. The server MUST NOT use
+ * another channel's progress to advance this checkpoint, and MUST NOT
+ * use it to seed the connection's global `serverSeq` identity (see
+ * {@link InitializeResult.serverSeq}).
+ */
+ lastSeenServerSeq: number;
+}
+
+/**
+ * Discriminant for per-channel reconnect recovery outcomes.
*
* @category Commands
* @exhaustive
*/
-export const enum ReconnectResultType {
+export const enum ChannelRecoveryKind {
+ /** The server replayed the channel's missed actions. */
Replay = 'replay',
+ /** The gap for this channel exceeded its replay buffer; a fresh snapshot is provided instead. */
Snapshot = 'snapshot',
+ /** The channel can no longer be resumed (e.g. disposed, or no longer permitted). */
+ Missing = 'missing',
+}
+
+/**
+ * Recovery outcome for a channel that replayed cleanly.
+ *
+ * The server MUST include every action the channel missed since the
+ * matching `ChannelReplayCursor.lastSeenServerSeq`, in ascending `serverSeq`
+ * order, and MUST only include actions whose `ActionEnvelope.channel`
+ * equals `channel`.
+ *
+ * @category Commands
+ */
+export interface ChannelReplayRecovery {
+ /** Discriminant */
+ kind: ChannelRecoveryKind.Replay;
+ /** The channel this recovery applies to. */
+ channel: URI;
+ /** Missed action envelopes since the requested `lastSeenServerSeq`. */
+ actions: ActionEnvelope[];
}
/**
- * Re-establishes a dropped connection. The server replays missed actions or
- * provides fresh snapshots.
+ * Recovery outcome for a channel whose gap exceeded its replay buffer.
+ *
+ * Absent for stateless channels that have no state to snapshot; the server
+ * MUST instead use {@link ChannelReplayRecovery} with an empty `actions`
+ * list (or {@link ChannelMissingRecovery}, if the channel itself no longer
+ * exists) for those.
+ *
+ * @category Commands
+ */
+export interface ChannelSnapshotRecovery {
+ /** Discriminant */
+ kind: ChannelRecoveryKind.Snapshot;
+ /** The channel this recovery applies to. */
+ channel: URI;
+ /** Fresh snapshot the client MUST use as its new baseline for this channel. */
+ snapshot: Snapshot;
+}
+
+/**
+ * Recovery outcome for a channel the server cannot resume — e.g. a disposed
+ * session or terminal, or a resource the client is no longer permitted to
+ * observe. Clients SHOULD drop `channel` from their local subscription set.
+ *
+ * @category Commands
+ */
+export interface ChannelMissingRecovery {
+ /** Discriminant */
+ kind: ChannelRecoveryKind.Missing;
+ /** The channel this recovery applies to. */
+ channel: URI;
+}
+
+/** Per-channel reconnect recovery outcome. */
+export type ChannelRecovery = ChannelReplayRecovery | ChannelSnapshotRecovery | ChannelMissingRecovery;
+
+/**
+ * Re-establishes a dropped connection. The server recovers each subscribed
+ * channel independently — some channels may replay, others may receive a
+ * fresh snapshot, and others may be reported missing, all in the same
+ * response (see {@link ChannelRecovery}).
*
* @category Commands
* @method reconnect
@@ -430,44 +524,22 @@ export interface ReconnectParams extends BaseParams {
channel: 'ahp-root://';
/** Client identifier from the original connection */
clientId: string;
- /** Last `serverSeq` the client received */
- lastSeenServerSeq: number;
- /** URIs the client was subscribed to */
- subscriptions: URI[];
+ /** Per-channel replay checkpoints for every channel the client is still subscribed to. */
+ subscriptions: ChannelReplayCursor[];
}
/**
- * Reconnect result when the server can replay from the requested sequence.
+ * Result of the `reconnect` command.
*
- * The server MUST include all replayed data in the response.
- */
-export interface ReconnectReplayResult {
- /** Discriminant */
- type: ReconnectResultType.Replay;
- /** Missed action envelopes since `lastSeenServerSeq` */
- actions: ActionEnvelope[];
- /**
- * URIs from `ReconnectParams.subscriptions` that the server cannot resume.
- * This includes resources that no longer exist (e.g. disposed sessions or
- * terminals) as well as resources the client is no longer permitted to
- * observe. Clients SHOULD drop these from their local subscription set.
- */
- missing: URI[];
-}
-
-/**
- * Reconnect result when the gap exceeds the replay buffer.
+ * The server MUST include all replayed and snapshotted data in the response
+ * before returning, and MUST include exactly one {@link ChannelRecovery} per
+ * channel named in `ReconnectParams.subscriptions`.
*/
-export interface ReconnectSnapshotResult {
- /** Discriminant */
- type: ReconnectResultType.Snapshot;
- /** Fresh snapshots for each subscription */
- snapshots: Snapshot[];
+export interface ReconnectResult {
+ /** One recovery outcome per requested subscription, in any order. */
+ channels: ChannelRecovery[];
}
-/** Result of the `reconnect` command. */
-export type ReconnectResult = ReconnectReplayResult | ReconnectSnapshotResult;
-
// ─── subscribe ───────────────────────────────────────────────────────────────
/**
diff --git a/types/test-cases/round-trips/054-reconnect-params-per-channel-cursors.json b/types/test-cases/round-trips/054-reconnect-params-per-channel-cursors.json
new file mode 100644
index 000000000..241d915f2
--- /dev/null
+++ b/types/test-cases/round-trips/054-reconnect-params-per-channel-cursors.json
@@ -0,0 +1,26 @@
+{
+ "name": "reconnect-params-per-channel-cursors",
+ "group": "A",
+ "description": "ReconnectParams carries one ChannelReplayCursor per subscribed channel instead of a single connection-wide watermark; two channels at genuinely divergent checkpoints (100 vs 101) round-trip independently, and a never-caught-up channel keeps lastSeenServerSeq=0.",
+ "type": "ReconnectParams",
+ "input": {
+ "channel": "ahp-root://",
+ "clientId": "client-1",
+ "subscriptions": [
+ { "channel": "ahp-session:/a", "lastSeenServerSeq": 100 },
+ { "channel": "ahp-session:/b", "lastSeenServerSeq": 101 },
+ { "channel": "ahp-canvas:/c", "lastSeenServerSeq": 0 }
+ ]
+ },
+ "acceptableOutputs": [
+ {
+ "channel": "ahp-root://",
+ "clientId": "client-1",
+ "subscriptions": [
+ { "channel": "ahp-session:/a", "lastSeenServerSeq": 100 },
+ { "channel": "ahp-session:/b", "lastSeenServerSeq": 101 },
+ { "channel": "ahp-canvas:/c", "lastSeenServerSeq": 0 }
+ ]
+ }
+ ]
+}
diff --git a/types/test-cases/round-trips/055-reconnect-result-mixed-channel-recovery.json b/types/test-cases/round-trips/055-reconnect-result-mixed-channel-recovery.json
new file mode 100644
index 000000000..c96830655
--- /dev/null
+++ b/types/test-cases/round-trips/055-reconnect-result-mixed-channel-recovery.json
@@ -0,0 +1,84 @@
+{
+ "name": "reconnect-result-mixed-channel-recovery",
+ "group": "A",
+ "description": "ReconnectResult carries one ChannelRecovery per requested subscription and the three kinds can differ per channel in the same response: channel A replays its missed actions in ascending serverSeq order, channel B's gap exceeded its replay buffer so it gets a fresh snapshot (fromSeq becomes its new baseline), and channel C is reported missing because the server can no longer resume it.",
+ "type": "ReconnectResult",
+ "input": {
+ "channels": [
+ {
+ "kind": "replay",
+ "channel": "ahp-session:/a",
+ "actions": [
+ {
+ "channel": "ahp-session:/a",
+ "action": { "type": "session/titleChanged", "title": "Hello" },
+ "serverSeq": 101
+ },
+ {
+ "channel": "ahp-session:/a",
+ "action": { "type": "session/titleChanged", "title": "Hello again" },
+ "serverSeq": 102
+ }
+ ]
+ },
+ {
+ "kind": "snapshot",
+ "channel": "ahp-canvas:/b",
+ "snapshot": {
+ "resource": "ahp-canvas:/b",
+ "state": {
+ "instanceId": "b",
+ "extensionId": "project:preview",
+ "canvasId": "b",
+ "status": "Reconnecting"
+ },
+ "fromSeq": 50
+ }
+ },
+ {
+ "kind": "missing",
+ "channel": "ahp-terminal:/c"
+ }
+ ]
+ },
+ "acceptableOutputs": [
+ {
+ "channels": [
+ {
+ "kind": "replay",
+ "channel": "ahp-session:/a",
+ "actions": [
+ {
+ "channel": "ahp-session:/a",
+ "action": { "type": "session/titleChanged", "title": "Hello" },
+ "serverSeq": 101
+ },
+ {
+ "channel": "ahp-session:/a",
+ "action": { "type": "session/titleChanged", "title": "Hello again" },
+ "serverSeq": 102
+ }
+ ]
+ },
+ {
+ "kind": "snapshot",
+ "channel": "ahp-canvas:/b",
+ "snapshot": {
+ "resource": "ahp-canvas:/b",
+ "state": {
+ "instanceId": "b",
+ "extensionId": "project:preview",
+ "canvasId": "b",
+ "status": "Reconnecting"
+ },
+ "fromSeq": 50
+ }
+ },
+ {
+ "kind": "missing",
+ "channel": "ahp-terminal:/c"
+ }
+ ]
+ }
+ ]
+}