import { beforeEach, describe, expect, it, vi } from 'vitest' import { JsonRpcGatewayClient } from './json-rpc-gateway' /** * Minimal EventTarget-based WebSocket stand-in so the seq-tracking and * replay-resume logic can be driven with real dispatch semantics. */ class FakeWebSocket extends EventTarget { static OPEN = 1 static instances: FakeWebSocket[] = [] readyState = 0 sent: string[] = [] url: string constructor(url: string) { super() this.url = url FakeWebSocket.instances.push(this) } send(data: string): void { this.sent.push(data) } close(): void { this.readyState = 3 this.dispatchEvent(new CloseEvent('close')) } // Test drivers open(): void { this.readyState = 1 this.dispatchEvent(new Event('open')) } serverFrame(obj: unknown): void { this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(obj) })) } lastRequest(): { id: string; method: string; params: Record } { const last = this.sent[this.sent.length - 1] return JSON.parse(last ?? '{}') } } let sockets: FakeWebSocket[] const makeClient = () => { const client = new JsonRpcGatewayClient({ socketFactory: url => new FakeWebSocket(url) as unknown as WebSocket, heartbeatIntervalMs: 0, heartbeatDeadlineMs: 0, connectTimeoutMs: 1000 }) return client } describe('JsonRpcGatewayClient event-seq tracking + replay resume', () => { beforeEach(() => { FakeWebSocket.instances = [] sockets = FakeWebSocket.instances as unknown as FakeWebSocket[] }) it('records per-session seq watermarks from live events', async () => { const client = makeClient() const p = client.connect('ws://x') sockets[0].open() await p sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 4 } }) sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 2 } }) // out of order / late sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'tool.start', session_id: 's2', seq: 9 } }) sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'skin.changed' } }) // no sid/seq expect(client.getSeqWatermarks()).toEqual({ s1: 4, s2: 9 }) client.close() }) it('fetches replay on reconnect for sessions it has watermarks for', async () => { const client = makeClient() const first = client.connect('ws://x') let sock = sockets[sockets.length - 1] sock.open() await first sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.start', session_id: 's1', seq: 1 } }) sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 5 } }) // Drop and reconnect. client.invalidate('drop') const second = client.connect('ws://x') sock = sockets[sockets.length - 1] sock.open() await second // The reconnect triggered a replay fetch — flush microtasks. await vi.waitFor(() => { const req = sock.lastRequest() expect(req.method).toBe('session.events.since') expect(req.params).toMatchObject({ session_id: 's1', last_seen: 5 }) }) client.close() }) it('dispatches replayed events through the normal handler path', async () => { const client = makeClient() const seen: string[] = [] client.on('tool.complete', e => seen.push(`live:${String((e.payload as { n?: number }).n)}`)) const first = client.connect('ws://x') let sock = sockets[sockets.length - 1] sock.open() await first sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 3 } }) client.invalidate('drop') const second = client.connect('ws://x') sock = sockets[sockets.length - 1] sock.open() await second await vi.waitFor(async () => { const req = sock.lastRequest() expect(req.method).toBe('session.events.since') // Answer the replay request with two missed events. sock.serverFrame({ jsonrpc: '2.0', id: req.id, result: { events: [ { type: 'tool.complete', session_id: 's1', seq: 4, payload: { n: 1 } }, { type: 'tool.complete', session_id: 's1', seq: 5, payload: { n: 2 } } ], latest_seq: 5, truncated: false, count: 2 } }) await Promise.resolve() expect(seen).toEqual(['live:1', 'live:2']) }) expect(client.getSeqWatermarks().s1).toBe(5) client.close() }) it('does not attempt replay when nothing was ever observed', async () => { const client = makeClient() const p = client.connect('ws://x') sockets[0].open() await p // No events ever seen → close+reconnect must NOT fire a replay RPC. client.invalidate('drop') const p2 = client.connect('ws://x') sockets[sockets.length - 1].open() await p2 await new Promise(r => setTimeout(r, 20)) expect(sockets[sockets.length - 1].sent).toHaveLength(0) client.close() }) it('replayed seqs advance watermarks but never regress them', async () => { const client = makeClient() const first = client.connect('ws://x') let sock = sockets[sockets.length - 1] sock.open() await first sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'status.update', session_id: 's1', seq: 10 } }) client.invalidate('drop') const second = client.connect('ws://x') sock = sockets[sockets.length - 1] sock.open() await second await vi.waitFor(() => { expect(sock.lastRequest().method).toBe('session.events.since') }) // Replay returns a STALE frame (seq 2 < watermark 10): watermark must hold. const req = sock.lastRequest() sock.serverFrame({ jsonrpc: '2.0', id: req.id, result: { events: [{ type: 'status.update', session_id: 's1', seq: 2 }], latest_seq: 10, truncated: false, count: 1 } }) await Promise.resolve() expect(client.getSeqWatermarks().s1).toBe(10) client.close() }) it('rejects envelope-shaped replay elements (the #94219 server-shape bug)', async () => { const client = makeClient() const seen: string[] = [] client.on('message.delta', () => seen.push('delta')) const first = client.connect('ws://x') let sock = sockets[sockets.length - 1] sock.open() await first sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 1 } }) expect(seen).toEqual(['delta']) // the pre-drop live frame client.invalidate('drop') const second = client.connect('ws://x') sock = sockets[sockets.length - 1] sock.open() await second await vi.waitFor(() => { expect(sock.lastRequest().method).toBe('session.events.since') }) const req = sock.lastRequest() // Pre-fix servers returned FULL JSON-RPC envelopes. The client must not // dispatch those blindly — and this documents why the server now sends // bare event objects. sock.serverFrame({ jsonrpc: '2.0', id: req.id, result: { events: [{ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 2 } }], latest_seq: 2, truncated: false, count: 1 } }) await Promise.resolve() // Envelope-shaped replay elements must add nothing beyond the live frame. expect(seen).toEqual(['delta']) client.close() }) it('holds live frames racing the replay fetch — no double dispatch, no skipped gap', async () => { const client = makeClient() const seen: number[] = [] client.on('message.delta', e => seen.push((e as unknown as { seq: number }).seq)) const first = client.connect('ws://x') let sock = sockets[sockets.length - 1] sock.open() await first sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 2 } }) expect(seen).toEqual([2]) // pre-drop live frame dispatches normally client.invalidate('drop') const second = client.connect('ws://x') sock = sockets[sockets.length - 1] sock.open() await second await vi.waitFor(() => { expect(sock.lastRequest().method).toBe('session.events.since') }) // LIVE frames 5 and 6 arrive while the replay (which carries 3,4,5) is // still in flight. They must be parked, not dispatched ahead of the gap. sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 5 } }) sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 6 } }) expect(seen).toEqual([2]) // still only the pre-drop frame — 5/6 parked const req = sock.lastRequest() sock.serverFrame({ jsonrpc: '2.0', id: req.id, result: { events: [ { type: 'message.delta', session_id: 's1', seq: 3 }, { type: 'message.delta', session_id: 's1', seq: 4 }, { type: 'message.delta', session_id: 's1', seq: 5 } ], latest_seq: 5, truncated: false, count: 3 } }) // In-order, exactly once: replayed 3,4,5 then the parked live 6 — // the parked duplicate of 5 is seq-gated out. await vi.waitFor(() => { expect(seen).toEqual([2, 3, 4, 5, 6]) }) expect(client.getSeqWatermarks().s1).toBe(6) client.close() }) it('clears stale watermarks when the backend epoch changes (restart poisoning)', async () => { const client = makeClient() const first = client.connect('ws://x') let sock = sockets[sockets.length - 1] sock.open() await first // Learn epoch A and a high watermark. sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'epoch-A' } } }) sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 97 } }) expect(client.getSeqWatermarks()).toEqual({ s1: 97 }) // Backend restarts: reconnect, replay under a NEW epoch returns nothing // (fresh process, empty ring) — pre-fix the client kept watermark 97 and // silently believed it missed nothing, forever. client.invalidate('drop') const second = client.connect('ws://x') sock = sockets[sockets.length - 1] sock.open() await second await vi.waitFor(() => { expect(sock.lastRequest().method).toBe('session.events.since') }) const req = sock.lastRequest() sock.serverFrame({ jsonrpc: '2.0', id: req.id, result: { events: [], latest_seq: 0, truncated: false, count: 0, epoch: 'epoch-B' } }) await vi.waitFor(() => { expect(client.getSeqWatermarks()).toEqual({}) }) // New-epoch events build fresh watermarks from scratch. sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 3 } }) expect(client.getSeqWatermarks()).toEqual({ s1: 3 }) client.close() }) })