feat(clients): CLI-15 surface ReplayGap reconnect sentinel as typed signal (4/5 clients)
The gateway emits a ReplayGap sentinel MxEvent at the head of a StreamEvents stream resumed via after_worker_sequence when the requested cursor predates the oldest retained event. Clients previously ignored it, silently mis-treating a lossy resume as continuous. Each client now surfaces the sentinel as a distinct, typed, non-terminal signal (never synthesized, never swallowed) so a consumer can detect the gap and re-snapshot; resume contract is after_worker_sequence = oldest_available_sequence - 1. - .NET: MxEventStreamItem (IsReplayGap/ReplayGap/Event) via new StreamEventItemsAsync + AsStreamItemsAsync extension. Build clean, 87 passed. - Go: EventResult.ReplayGap field + IsReplayGap(); ReplayGap type alias. build/vet/test clean. - Rust: EventItem enum (Event/ReplayGap); EventStream now yields Result<EventItem, Error>; CLI renders REPLAY_GAP line / replayGap JSON. fmt/check/test/clippy clean. - Python: ReplayGap dataclass; stream_events yields pb.MxEvent | ReplayGap. 131 passed. - Shared docs: ClientLibrariesDesign non-goals reframed (reconnect-replay protocol is consumable; auto-reconnect stays a non-goal); CrossLanguageSmokeMatrix resume-gap note. Java client is deferred to the windev batch (no local JRE); CLI-15 stays open until it lands. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
@@ -215,6 +215,55 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal("session-fixture", request.SessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a reconnect-replay gap sentinel is surfaced as a typed
|
||||
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
|
||||
/// events pass through unchanged with IsReplayGap false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddEvent(new MxEvent
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
ReplayGap = new ReplayGap
|
||||
{
|
||||
RequestedAfterSequence = 5,
|
||||
OldestAvailableSequence = 42,
|
||||
},
|
||||
});
|
||||
transport.AddEvent(new MxEvent
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
WorkerSequence = 42,
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
List<MxEventStreamItem> items = [];
|
||||
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(afterWorkerSequence: 5))
|
||||
{
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
Assert.Equal(2, items.Count);
|
||||
|
||||
MxEventStreamItem gap = items[0];
|
||||
Assert.True(gap.IsReplayGap);
|
||||
Assert.NotNull(gap.ReplayGap);
|
||||
Assert.Equal(5UL, gap.ReplayGap!.RequestedAfterSequence);
|
||||
Assert.Equal(42UL, gap.ReplayGap.OldestAvailableSequence);
|
||||
Assert.Same(gap.ReplayGap, gap.Event.ReplayGap);
|
||||
|
||||
MxEventStreamItem normal = items[1];
|
||||
Assert.False(normal.IsReplayGap);
|
||||
Assert.Null(normal.ReplayGap);
|
||||
Assert.Equal(42UL, normal.Event.WorkerSequence);
|
||||
Assert.Equal(MxEventFamily.OnDataChange, normal.Event.Family);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
||||
[Fact]
|
||||
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||
|
||||
Reference in New Issue
Block a user