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:
Joseph Doherty
2026-07-09 16:22:28 -04:00
parent c823a7b60b
commit 0c6e5b3ace
22 changed files with 972 additions and 37 deletions
@@ -200,6 +200,63 @@ func TestEventsSlowConsumerYieldsErrSlowConsumerBeforeClose(t *testing.T) {
}
}
func TestEventsSurfacesReplayGapSentinelAsTypedSignal(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamReplayGap: &pb.ReplayGap{
RequestedAfterSequence: 5,
OldestAvailableSequence: 42,
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events, err := session.EventsAfter(ctx, 5)
if err != nil {
t.Fatalf("EventsAfter() error = %v", err)
}
<-fake.streamStarted
// First result must be the typed replay-gap signal, not a normal event.
first := <-events
if first.Err != nil {
t.Fatalf("first result error = %v", first.Err)
}
if !first.IsReplayGap() {
t.Fatalf("first result IsReplayGap() = false, want true")
}
if first.Event != nil {
t.Fatalf("replay-gap result carried a non-nil Event %+v; want the sentinel to clear Event", first.Event)
}
if got := first.ReplayGap.GetRequestedAfterSequence(); got != 5 {
t.Fatalf("ReplayGap.RequestedAfterSequence = %d, want 5", got)
}
if got := first.ReplayGap.GetOldestAvailableSequence(); got != 42 {
t.Fatalf("ReplayGap.OldestAvailableSequence = %d, want 42", got)
}
// Normal events after the sentinel are unaffected: Event set, ReplayGap nil.
second := <-events
if second.Err != nil {
t.Fatalf("second result error = %v", second.Err)
}
if second.IsReplayGap() {
t.Fatalf("second result IsReplayGap() = true, want false for a normal event")
}
if second.Event == nil {
t.Fatal("second result Event = nil, want a normal event")
}
if got := second.Event.GetWorkerSequence(); got != 1 {
t.Fatalf("normal event worker sequence = %d, want 1", got)
}
if second.Event.GetFamily() != pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE {
t.Fatalf("normal event family = %s, want ON_DATA_CHANGE", second.Event.GetFamily())
}
}
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
@@ -643,6 +700,7 @@ type fakeGatewayServer struct {
streamStarted chan struct{}
streamDone chan struct{}
streamEventCount int
streamReplayGap *pb.ReplayGap
invokeReply *pb.MxCommandReply
invokeRequest *pb.MxCommandRequest
}
@@ -691,6 +749,16 @@ func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grp
if s.streamStarted != nil {
close(s.streamStarted)
}
if s.streamReplayGap != nil {
// Emit the reconnect-replay gap sentinel at the head of the resumed
// stream: family UNSPECIFIED, body unset, only replay_gap populated.
if err := stream.Send(&pb.MxEvent{
SessionId: req.GetSessionId(),
ReplayGap: s.streamReplayGap,
}); err != nil {
return err
}
}
eventCount := s.streamEventCount
if eventCount == 0 {
eventCount = 1