History

Add durable provider event catch-up by sequence

- Persist provider event `seq` metadata and return persisted events from ingestion - Add `providers.catchUp` WS/API contract and server persistence query - Replay missing provider events on web reconnect using ordered sequence catch-up - Extend server/contracts tests for sequence ordering and catch-up behavior

Julius Marminge committed 8 months ago

Commit f8aadcf1 parent b9bb89c13 files · +400 −22Browse files

Unified · Split

13 files
  1. apps/server/src/persistenceService.test.ts +26 −4
  2. apps/server/src/persistenceService.ts +105 −3
  3. apps/server/src/providerManager.test.ts +52 −0
  4. apps/server/src/providerManager.ts +26 −13
  5. apps/server/src/wsServer.test.ts +52 −0
  6. apps/server/src/wsServer.ts +3 −0
  7. apps/web/src/routes/__root.tsx +72 −2
  8. apps/web/src/wsNativeApi.ts +1 −0
  9. packages/contracts/src/ipc.ts +3 −0
  10. packages/contracts/src/provider.test.ts +42 −0
  11. packages/contracts/src/provider.ts +13 −0
  12. packages/contracts/src/ws.test.ts +4 −0
  13. packages/contracts/src/ws.ts +1 −0
apps/server/src/persistenceService.test.ts+26−4
@@ -102,7 +102,7 @@ describe("PersistenceService", () => { · 101 unmodified lines
102102 sessionId: "sess-1",
103103 };
104104
105− service.ingestProviderEvent({
105+ const event1 = service.ingestProviderEvent({
106106 ...baseEvent,
107107 id: "evt-1",
108108 createdAt: iso(10),
@@ -115,8 +115,9 @@ describe("PersistenceService", () => { · 6 unmodified lines
115115 },
116116 },
117117 });
118+ expect(event1?.seq).toBeGreaterThan(0);
118119
119− service.ingestProviderEvent({
120+ const event2 = service.ingestProviderEvent({
120121 ...baseEvent,
121122 id: "evt-2",
122123 createdAt: iso(20),
@@ -128,8 +129,9 @@ describe("PersistenceService", () => { · 5 unmodified lines
128129 delta: "hi",
129130 },
130131 });
132+ expect((event2?.seq ?? 0) > (event1?.seq ?? 0)).toBe(true);
131133
132− service.ingestProviderEvent({
134+ const event3 = service.ingestProviderEvent({
133135 ...baseEvent,
134136 id: "evt-3",
135137 createdAt: iso(30),
@@ -142,8 +144,9 @@ describe("PersistenceService", () => { · 6 unmodified lines
142144 },
143145 },
144146 });
147+ expect((event3?.seq ?? 0) > (event2?.seq ?? 0)).toBe(true);
145148
146− service.ingestProviderEvent({
149+ const event4 = service.ingestProviderEvent({
147150 ...baseEvent,
148151 id: "evt-4",
149152 createdAt: iso(40),
@@ -156,6 +159,7 @@ describe("PersistenceService", () => { · 6 unmodified lines
156159 },
157160 },
158161 });
162+ expect((event4?.seq ?? 0) > (event3?.seq ?? 0)).toBe(true);
159163
160164 const snapshot = service.loadSnapshot();
161165 expect(snapshot.projects).toHaveLength(1);
@@ -179,6 +183,24 @@ describe("PersistenceService", () => { · 17 unmodified lines
179183 expect(catchUp.events.some((event) => event.eventType === "project.upsert")).toBe(true);
180184 expect(catchUp.events.some((event) => event.eventType === "thread.upsert")).toBe(true);
181185 expect(catchUp.events.some((event) => event.eventType === "message.upsert")).toBe(true);
186+
187+ const providerCatchUp = service.providerCatchUp({ afterSeq: 0 });
188+ expect(providerCatchUp.lastProviderSeq).toBe(event4?.seq ?? 0);
189+ expect(providerCatchUp.events.map((event) => event.id)).toEqual([
190+ "evt-1",
191+ "evt-2",
192+ "evt-3",
193+ "evt-4",
194+ ]);
195+ const providerSeqs = providerCatchUp.events.map((event) => event.seq ?? 0);
196+ for (let index = 1; index < providerSeqs.length; index += 1) {
197+ expect(providerSeqs[index]).toBeGreaterThan(providerSeqs[index - 1] ?? Number.NEGATIVE_INFINITY);
198+ }
199+
200+ const providerCatchUpAfterSecond = service.providerCatchUp({
201+ afterSeq: event2?.seq ?? 0,
202+ });
203+ expect(providerCatchUpAfterSecond.events.map((event) => event.id)).toEqual(["evt-3", "evt-4"]);
182204 } finally {
183205 service.close();
184206 }
apps/server/src/persistenceService.ts+105−3
@@ -16,6 +16,8 @@ import { · 15 unmodified lines
1616 type ProjectRemoveInput,
1717 type ProjectUpdateScriptsInput,
1818 type ProjectUpdateScriptsResult,
19+ type ProviderCatchUpInput,
20+ type ProviderCatchUpResult,
1921 type ProviderEvent,
2022 type ProviderSendTurnInput,
2123 type StateBootstrapResult,
@@ -44,6 +46,9 @@ import { · 22 unmodified lines
4446 projectRemoveInputSchema,
4547 projectScriptsSchema,
4648 projectUpdateScriptsInputSchema,
49+ providerCatchUpInputSchema,
50+ providerCatchUpResultSchema,
51+ providerEventSchema,
4752 stateBootstrapResultSchema,
4853 stateCatchUpInputSchema,
4954 stateCatchUpResultSchema,
@@ -94,6 +99,25 @@ interface StateEventRow { · 44 unmodified lines
9499 interface ProviderEventInsertResult {
95100 inserted: boolean;
96101 runtimeThreadId: string | null;
102+ seq: number;
103+}
104+
105+interface ProviderEventRow {
106+ seq: number;
107+ id: string;
108+ session_id: string;
109+ provider: string;
110+ kind: string;
111+ method: string;
112+ thread_id: string | null;
113+ turn_id: string | null;
114+ item_id: string | null;
115+ request_id: string | null;
116+ request_kind: string | null;
117+ text_delta: string | null;
118+ message: string | null;
119+ payload_json: string | null;
120+ created_at: string;
97121 }
98122
99123 export interface PersistenceServiceOptions {
@@ -772,6 +796,43 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 672 unmodified lines
772796 });
773797 }
774798
799+ providerCatchUp(raw: ProviderCatchUpInput): ProviderCatchUpResult {
800+ const input = providerCatchUpInputSchema.parse(raw);
801+ const rows = this.db
802+ .prepare(
803+ `SELECT
804+ seq,
805+ id,
806+ session_id,
807+ provider,
808+ kind,
809+ method,
810+ thread_id,
811+ turn_id,
812+ item_id,
813+ request_id,
814+ request_kind,
815+ text_delta,
816+ message,
817+ payload_json,
818+ created_at
819+ FROM provider_events
820+ WHERE seq > ?
821+ ORDER BY seq ASC
822+ LIMIT ?;`,
823+ )
824+ .all(input.afterSeq, input.limit) as unknown as ProviderEventRow[];
825+
826+ const events = rows
827+ .map((row) => this.parseProviderEventRow(row))
828+ .filter((event): event is ProviderEvent => event !== null);
829+
830+ return providerCatchUpResultSchema.parse({
831+ events,
832+ lastProviderSeq: this.readLastProviderSeq(),
833+ });
834+ }
835+
775836 listMessages(raw: StateListMessagesInput): StateListMessagesResult {
776837 const input = stateListMessagesInputSchema.parse(raw);
777838 const rows = this.db
@@ -867,12 +928,18 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 89 unmodified lines
867928 });
868929 }
869930
870− ingestProviderEvent(event: ProviderEvent): void {
931+ ingestProviderEvent(event: ProviderEvent): ProviderEvent | null {
932+ let persistedEvent: ProviderEvent | null = null;
871933 this.withTransaction((pendingEvents) => {
872934 const insertResult = this.insertProviderEvent(event);
873935 if (!insertResult.inserted) {
874936 return;
875937 }
938+ persistedEvent = providerEventSchema.parse({
939+ ...event,
940+ seq: insertResult.seq,
941+ ...(insertResult.runtimeThreadId ? { threadId: insertResult.runtimeThreadId } : {}),
942+ });
876943
877944 const localThreadId = this.resolveThreadIdForEvent(event, insertResult.runtimeThreadId);
878945 if (!localThreadId) {
@@ -1138,6 +1205,7 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 259 unmodified lines
11381205 }
11391206 }
11401207 });
1208+ return persistedEvent;
11411209 }
11421210
11431211 persistTurnDiffSummaryFromCheckpoint(input: {
@@ -1360,6 +1428,13 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 216 unmodified lines
13601428 return row?.seq ?? 0;
13611429 }
13621430
1431+ private readLastProviderSeq(): number {
1432+ const row = this.db
1433+ .prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM provider_events;")
1434+ .get() as { seq: number } | undefined;
1435+ return row?.seq ?? 0;
1436+ }
1437+
13631438 private withTransaction<T>(fn: (pendingEvents: StateEvent[]) => T): T {
13641439 const pendingEvents: StateEvent[] = [];
13651440 const result = this.stateDb.transaction(() => fn(pendingEvents));
@@ -1766,13 +1841,40 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 400 unmodified lines
17661841 event.message ?? null,
17671842 payloadJson,
17681843 event.createdAt,
1769− ) as { changes?: number | bigint };
1844+ ) as { changes?: number | bigint; lastInsertRowid?: number | bigint };
1845+ const inserted = toSafeInteger(result.changes, 0) > 0;
17701846 return {
1771− inserted: toSafeInteger(result.changes, 0) > 0,
1847+ inserted,
17721848 runtimeThreadId: runtimeThreadId ?? null,
1849+ seq: inserted ? toSafeInteger(result.lastInsertRowid, 0) : 0,
17731850 };
17741851 }
17751852
1853+ private parseProviderEventRow(row: ProviderEventRow): ProviderEvent | null {
1854+ const payload = row.payload_json ? this.tryParseJson(row.payload_json) : undefined;
1855+ const parsed = providerEventSchema.safeParse({
1856+ seq: row.seq,
1857+ id: row.id,
1858+ kind: row.kind,
1859+ provider: row.provider,
1860+ sessionId: row.session_id,
1861+ createdAt: row.created_at,
1862+ method: row.method,
1863+ ...(row.thread_id ? { threadId: row.thread_id } : {}),
1864+ ...(row.turn_id ? { turnId: row.turn_id } : {}),
1865+ ...(row.item_id ? { itemId: row.item_id } : {}),
1866+ ...(row.request_id ? { requestId: row.request_id } : {}),
1867+ ...(row.request_kind ? { requestKind: row.request_kind } : {}),
1868+ ...(row.text_delta !== null ? { textDelta: row.text_delta } : {}),
1869+ ...(row.message ? { message: row.message } : {}),
1870+ ...(payload !== undefined ? { payload } : {}),
1871+ });
1872+ if (!parsed.success) {
1873+ return null;
1874+ }
1875+ return parsed.data;
1876+ }
1877+
17761878 private parseJson<T>(json: string, schema: SafeParseSchema<T>): T | null {
17771879 const candidate = this.tryParseJson(json);
17781880 const parsed = schema.safeParse(candidate);
apps/server/src/providerManager.test.ts+52−0
@@ -3,6 +3,58 @@ import { describe, expect, it, vi } from "vitest"; · 2 unmodified lines
33 import { ProviderManager } from "./providerManager";
44
55 describe("ProviderManager", () => {
6+ it("emits persisted provider events with durable sequence metadata when available", () => {
7+ const ingestProviderEvent = vi.fn((event: {
8+ id: string;
9+ kind: "notification";
10+ provider: "codex";
11+ sessionId: string;
12+ createdAt: string;
13+ method: string;
14+ threadId: string;
15+ }) => ({
16+ ...event,
17+ seq: 11,
18+ }));
19+ const persistenceService = {
20+ ingestProviderEvent,
21+ unbindSession: vi.fn(),
22+ };
23+ const manager = new ProviderManager({
24+ persistenceService: persistenceService as never,
25+ });
26+ const events: Array<{ id: string; seq?: number }> = [];
27+ manager.on("event", (event) => {
28+ events.push({ id: event.id, seq: event.seq });
29+ });
30+
31+ const internals = manager as unknown as {
32+ onCodexEvent: (event: {
33+ id: string;
34+ kind: "notification";
35+ provider: "codex";
36+ sessionId: string;
37+ createdAt: string;
38+ method: string;
39+ threadId: string;
40+ }) => void;
41+ };
42+
43+ internals.onCodexEvent({
44+ id: "evt-seq-1",
45+ kind: "notification",
46+ provider: "codex",
47+ sessionId: "sess-1",
48+ createdAt: new Date().toISOString(),
49+ method: "turn/started",
50+ threadId: "thread-1",
51+ });
52+
53+ expect(ingestProviderEvent).toHaveBeenCalledTimes(1);
54+ expect(events).toEqual([{ id: "evt-seq-1", seq: 11 }]);
55+ manager.dispose();
56+ });
57+
658 it("detaches provider event listener and ends thread log streams on dispose", () => {
759 const manager = new ProviderManager();
860 const internals = manager as unknown as {
apps/server/src/providerManager.ts+26−13
@@ -180,18 +180,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 179 unmodified lines
180180 if (this.disposed) {
181181 return;
182182 }
183−
184− this.routeEventToThreadLog(event);
185− try {
186− this.persistenceService?.ingestProviderEvent(event);
187− } catch {
188− // Persistence failures should not break provider streaming.
189− }
190− if (event.method === "session/closed" || event.method === "session/exited") {
191− this.persistenceService?.unbindSession(event.sessionId);
183+ const emittedEvent = this.publishProviderEvent(event);
184+ if (!emittedEvent) {
185+ return;
192186 }
193− this.emit("event", event);
194− this.maybeCaptureFilesystemCheckpoint(event);
187+ this.maybeCaptureFilesystemCheckpoint(emittedEvent);
195188 };
196189
197190 constructor(options: ProviderManagerOptions = {}) {
@@ -546,7 +539,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 348 unmodified lines
546539 }
547540
548541 private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void {
549− this.emit("event", {
542+ this.publishProviderEvent({
550543 id: randomUUID(),
551544 kind: "notification",
552545 provider: "codex",
@@ -566,7 +559,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 13 unmodified lines
566559 message: string,
567560 threadId?: string,
568561 ): void {
569− this.emit("event", {
562+ this.publishProviderEvent({
570563 id: randomUUID(),
571564 kind: "error",
572565 provider: "codex",
@@ -578,6 +571,26 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 5 unmodified lines
578571 });
579572 }
580573
574+ private publishProviderEvent(event: ProviderEvent): ProviderEvent | null {
575+ let emittedEvent = event;
576+ try {
577+ const persistedEvent = this.persistenceService?.ingestProviderEvent(event);
578+ if (this.persistenceService && !persistedEvent) {
579+ return null;
580+ }
581+ emittedEvent = persistedEvent ?? event;
582+ } catch {
583+ // Persistence failures should not break provider streaming.
584+ }
585+
586+ this.routeEventToThreadLog(emittedEvent);
587+ if (emittedEvent.method === "session/closed" || emittedEvent.method === "session/exited") {
588+ this.persistenceService?.unbindSession(emittedEvent.sessionId);
589+ }
590+ this.emit("event", emittedEvent);
591+ return emittedEvent;
592+ }
593+
581594 private withFilesystemLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
582595 const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve();
583596 const next = previous.catch(() => undefined).then(fn);
apps/server/src/wsServer.test.ts+52−0
@@ -669,6 +669,58 @@ describe("WebSocket Server", () => { · 668 unmodified lines
669669 expect(response.result).toEqual([]);
670670 });
671671
672+ it("returns durable provider catch-up events ordered by seq", async () => {
673+ const stateDir = makeTempDir("t3code-ws-provider-catchup-");
674+ const persistenceService = new PersistenceService({
675+ dbPath: path.join(stateDir, "state.sqlite"),
676+ legacyProjectsJsonPath: path.join(stateDir, "projects.json"),
677+ });
678+
679+ const first = persistenceService.ingestProviderEvent({
680+ id: "provider-evt-1",
681+ kind: "notification",
682+ provider: "codex",
683+ sessionId: "sess-1",
684+ createdAt: "2026-02-20T00:00:00.000Z",
685+ method: "turn/started",
686+ turnId: "turn-1",
687+ payload: { turn: { id: "turn-1" } },
688+ });
689+ const second = persistenceService.ingestProviderEvent({
690+ id: "provider-evt-2",
691+ kind: "notification",
692+ provider: "codex",
693+ sessionId: "sess-1",
694+ createdAt: "2026-02-20T00:00:01.000Z",
695+ method: "turn/completed",
696+ turnId: "turn-1",
697+ payload: { turn: { id: "turn-1", status: "completed" } },
698+ });
699+
700+ server = createTestServer({ cwd: "/test", stateDir, persistenceService });
701+ await server.start();
702+ const addr = server.httpServer.address();
703+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
704+
705+ const ws = await connectWs(port);
706+ connections.push(ws);
707+ await waitForMessage(ws);
708+
709+ const response = await sendRequest(ws, WS_METHODS.providersCatchUp, {
710+ afterSeq: first?.seq ?? 0,
711+ });
712+ expect(response.error).toBeUndefined();
713+ expect(response.result).toEqual({
714+ events: [
715+ expect.objectContaining({
716+ id: "provider-evt-2",
717+ seq: second?.seq,
718+ }),
719+ ],
720+ lastProviderSeq: second?.seq ?? 0,
721+ });
722+ });
723+
672724 it("persists app settings through appSettings RPC methods", async () => {
673725 const stateDir = makeTempDir("t3code-ws-app-settings-");
674726 server = createTestServer({ cwd: "/test", stateDir });
apps/server/src/wsServer.ts+3−0
@@ -334,6 +334,9 @@ export function createServer(options: ServerOptions) { · 333 unmodified lines
334334 case WS_METHODS.providersListSessions:
335335 return providerManager.listSessions();
336336
337+ case WS_METHODS.providersCatchUp:
338+ return persistenceService.providerCatchUp(request.params as never);
339+
337340 case WS_METHODS.providersListCheckpoints:
338341 return providerManager.listCheckpoints(request.params as never);
339342
apps/web/src/routes/__root.tsx+72−2
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 } from "@tanstack/react-router";
77 import { useEffect, useRef } from "react";
88 import { QueryClient, useQueryClient } from "@tanstack/react-query";
9+import type { ProviderEvent } from "@t3tools/contracts";
910
1011 import { APP_DISPLAY_NAME } from "../branding";
1112 import { Button } from "../components/ui/button";
@@ -120,6 +121,14 @@ function errorDetails(error: unknown): string { · 108 unmodified lines
120121 }
121122 }
122123
124+function readProviderSeq(event: ProviderEvent): number | null {
125+ const seq = event.seq;
126+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq <= 0) {
127+ return null;
128+ }
129+ return seq;
130+}
131+
123132 function StateSyncRouter() {
124133 const api = useNativeApi();
125134 const { dispatch } = useStore();
@@ -131,6 +140,8 @@ function StateSyncRouter() { · 5 unmodified lines
131140 });
132141 const lastStateSeqRef = useRef(0);
133142 const stateQueueRef = useRef(Promise.resolve());
143+ const lastProviderSeqRef = useRef(0);
144+ const providerQueueRef = useRef(Promise.resolve());
134145
135146 useEffect(() => {
136147 if (!api) return;
@@ -202,7 +213,7 @@ function StateSyncRouter() { · 65 unmodified lines
202213
203214 useEffect(() => {
204215 if (!api) return;
205− return api.providers.onEvent((event) => {
216+ const applyProviderEvent = (event: ProviderEvent) => {
206217 if (event.method === "turn/completed") {
207218 void invalidateGitQueries(queryClient);
208219 }
@@ -217,14 +228,73 @@ function StateSyncRouter() { · 8 unmodified lines
217228 },
218229 });
219230 }
220− if (!activeThreadId) return;
221231 dispatch({
222232 type: "APPLY_EVENT",
223233 event,
224234 activeAssistantItemRef,
225235 activeThreadId,
226236 });
237+ };
238+
239+ const replayProviderCatchUp = async (): Promise<void> => {
240+ const catchUp = await api.providers.catchUp({
241+ afterSeq: lastProviderSeqRef.current,
242+ });
243+ if (catchUp.events.length === 0) {
244+ lastProviderSeqRef.current = Math.max(lastProviderSeqRef.current, catchUp.lastProviderSeq);
245+ return;
246+ }
247+
248+ for (const missingEvent of catchUp.events) {
249+ const missingSeq = readProviderSeq(missingEvent);
250+ if (!missingSeq || missingSeq <= lastProviderSeqRef.current) continue;
251+ applyProviderEvent(missingEvent);
252+ lastProviderSeqRef.current = missingSeq;
253+ }
254+
255+ if (lastProviderSeqRef.current < catchUp.lastProviderSeq) {
256+ await replayProviderCatchUp();
257+ }
258+ };
259+
260+ const enqueueProviderWork = (work: () => Promise<void>) => {
261+ providerQueueRef.current = providerQueueRef.current.then(work).catch(() => undefined);
262+ };
263+
264+ const unsubscribeWelcome = onServerWelcome(() => {
265+ if (lastProviderSeqRef.current <= 0) {
266+ return;
267+ }
268+ enqueueProviderWork(async () => {
269+ await replayProviderCatchUp();
270+ });
227271 });
272+
273+ const unsubscribeProvider = api.providers.onEvent((event) => {
274+ enqueueProviderWork(async () => {
275+ const eventSeq = readProviderSeq(event);
276+ if (!eventSeq) {
277+ applyProviderEvent(event);
278+ return;
279+ }
280+ if (eventSeq <= lastProviderSeqRef.current) {
281+ return;
282+ }
283+ if (lastProviderSeqRef.current > 0 && eventSeq > lastProviderSeqRef.current + 1) {
284+ await replayProviderCatchUp();
285+ }
286+ if (eventSeq <= lastProviderSeqRef.current) {
287+ return;
288+ }
289+ applyProviderEvent(event);
290+ lastProviderSeqRef.current = eventSeq;
291+ });
292+ });
293+
294+ return () => {
295+ unsubscribeWelcome();
296+ unsubscribeProvider();
297+ };
228298 }, [activeThreadId, api, dispatch, queryClient]);
229299
230300 useEffect(() => {
apps/web/src/wsNativeApi.ts+1−0
@@ -93,6 +93,7 @@ export function createWsNativeApi(): NativeApi { · 92 unmodified lines
9393 listSessions: () => transport.request(WS_METHODS.providersListSessions),
9494 listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input),
9595 getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input),
96+ catchUp: (input) => transport.request(WS_METHODS.providersCatchUp, input),
9697 revertToCheckpoint: (input) =>
9798 transport.request(WS_METHODS.providersRevertToCheckpoint, input),
9899 onEvent: (callback) =>
packages/contracts/src/ipc.ts+3−0
@@ -17,6 +17,8 @@ import type { · 16 unmodified lines
1717 GitStatusResult,
1818 } from "./git";
1919 import type {
20+ ProviderCatchUpInput,
21+ ProviderCatchUpResult,
2022 ProviderEvent,
2123 ProviderGetCheckpointDiffInput,
2224 ProviderGetCheckpointDiffResult,
@@ -118,6 +120,7 @@ export interface NativeApi { · 95 unmodified lines
118120 getCheckpointDiff: (
119121 input: ProviderGetCheckpointDiffInput,
120122 ) => Promise<ProviderGetCheckpointDiffResult>;
123+ catchUp: (input: ProviderCatchUpInput) => Promise<ProviderCatchUpResult>;
121124 revertToCheckpoint: (
122125 input: ProviderRevertToCheckpointInput,
123126 ) => Promise<ProviderRevertToCheckpointResult>;
packages/contracts/src/provider.test.ts+42−0
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line
22
33 import {
44 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
5+ providerCatchUpInputSchema,
6+ providerCatchUpResultSchema,
57 providerCheckpointSchema,
68 providerEventSchema,
79 providerGetCheckpointDiffInputSchema,
@@ -156,6 +158,19 @@ describe("providerEventSchema", () => { · 148 unmodified lines
156158 expect(parsed.method).toBe("item/agentMessage/delta");
157159 });
158160
161+ it("accepts optional durable sequence metadata", () => {
162+ const parsed = providerEventSchema.parse({
163+ seq: 42,
164+ id: "evt_seq_1",
165+ kind: "notification",
166+ provider: "codex",
167+ sessionId: "sess_1",
168+ createdAt: "2026-01-01T00:00:00.000Z",
169+ method: "turn/completed",
170+ });
171+ expect(parsed.seq).toBe(42);
172+ });
173+
159174 it("accepts request approval metadata", () => {
160175 const parsed = providerEventSchema.parse({
161176 id: "evt_2",
@@ -172,6 +187,33 @@ describe("providerEventSchema", () => { · 10 unmodified lines
172187 });
173188 });
174189
190+describe("provider catch-up schemas", () => {
191+ it("parses catch-up input defaults", () => {
192+ const parsed = providerCatchUpInputSchema.parse({});
193+ expect(parsed.afterSeq).toBe(0);
194+ expect(parsed.limit).toBe(1_000);
195+ });
196+
197+ it("parses catch-up result payload", () => {
198+ const parsed = providerCatchUpResultSchema.parse({
199+ events: [
200+ {
201+ seq: 7,
202+ id: "evt_7",
203+ kind: "notification",
204+ provider: "codex",
205+ sessionId: "sess_1",
206+ createdAt: "2026-01-01T00:00:00.000Z",
207+ method: "turn/started",
208+ },
209+ ],
210+ lastProviderSeq: 9,
211+ });
212+ expect(parsed.events[0]?.seq).toBe(7);
213+ expect(parsed.lastProviderSeq).toBe(9);
214+ });
215+});
216+
175217 describe("providerRespondToRequestInputSchema", () => {
176218 it("accepts valid decisions", () => {
177219 const parsed = providerRespondToRequestInputSchema.parse({
packages/contracts/src/provider.ts+13−0
@@ -170,6 +170,11 @@ export const providerGetCheckpointDiffResultSchema = z.object({ · 169 unmodified lines
170170 diff: z.string(),
171171 });
172172
173+export const providerCatchUpInputSchema = z.object({
174+ afterSeq: z.number().int().min(0).default(0),
175+ limit: z.number().int().min(1).max(5_000).default(1_000),
176+});
177+
173178 export const providerRespondToRequestInputSchema = z.object({
174179 sessionId: z.string().min(1),
175180 requestId: z.string().min(1),
@@ -179,6 +184,7 @@ export const providerRespondToRequestInputSchema = z.object({ · 3 unmodified lines
179184 export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]);
180185
181186 export const providerEventSchema = z.object({
187+ seq: z.number().int().positive().optional(),
182188 id: z.string().min(1),
183189 kind: providerEventKindSchema,
184190 provider: providerKindSchema,
@@ -195,6 +201,11 @@ export const providerEventSchema = z.object({ · 10 unmodified lines
195201 payload: z.unknown().optional(),
196202 });
197203
204+export const providerCatchUpResultSchema = z.object({
205+ events: z.array(providerEventSchema),
206+ lastProviderSeq: z.number().int().min(0),
207+});
208+
198209 export type ProviderKind = z.infer<typeof providerKindSchema>;
199210 export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>;
200211 export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;
@@ -219,6 +230,8 @@ export type ProviderRevertToCheckpointResult = z.infer< · 18 unmodified lines
219230 >;
220231 export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>;
221232 export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>;
233+export type ProviderCatchUpInput = z.input<typeof providerCatchUpInputSchema>;
234+export type ProviderCatchUpResult = z.infer<typeof providerCatchUpResultSchema>;
222235 export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
223236 export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
224237 export type ProviderEvent = z.infer<typeof providerEventSchema>;
packages/contracts/src/ws.test.ts+4−0
@@ -7,4 +7,8 @@ describe("WS thread methods", () => { · 6 unmodified lines
77 expect(WS_METHODS.threadsUpdateTerminalState).toBe("threads.updateTerminalState");
88 expect("threadsUpdate" in WS_METHODS).toBe(false);
99 });
10+
11+ it("includes provider catch-up method for durable stream resume", () => {
12+ expect(WS_METHODS.providersCatchUp).toBe("providers.catchUp");
13+ });
1014 });
packages/contracts/src/ws.ts+1−0
@@ -28,6 +28,7 @@ export const WS_METHODS = { · 27 unmodified lines
2828 providersListSessions: "providers.listSessions",
2929 providersListCheckpoints: "providers.listCheckpoints",
3030 providersGetCheckpointDiff: "providers.getCheckpointDiff",
31+ providersCatchUp: "providers.catchUp",
3132 providersRevertToCheckpoint: "providers.revertToCheckpoint",
3233
3334 // Project registry methods