- 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
Unified · Split
| @@ -102,7 +102,7 @@ describe("PersistenceService", () => { · 101 unmodified lines | ||
| 102 | 102 | sessionId: "sess-1", |
| 103 | 103 | }; |
| 104 | 104 | |
| 105 | − service.ingestProviderEvent({ | |
| 105 | + const event1 = service.ingestProviderEvent({ | |
| 106 | 106 | ...baseEvent, |
| 107 | 107 | id: "evt-1", |
| 108 | 108 | createdAt: iso(10), |
| @@ -115,8 +115,9 @@ describe("PersistenceService", () => { · 6 unmodified lines | ||
| 115 | 115 | }, |
| 116 | 116 | }, |
| 117 | 117 | }); |
| 118 | + expect(event1?.seq).toBeGreaterThan(0); | |
| 118 | 119 | |
| 119 | − service.ingestProviderEvent({ | |
| 120 | + const event2 = service.ingestProviderEvent({ | |
| 120 | 121 | ...baseEvent, |
| 121 | 122 | id: "evt-2", |
| 122 | 123 | createdAt: iso(20), |
| @@ -128,8 +129,9 @@ describe("PersistenceService", () => { · 5 unmodified lines | ||
| 128 | 129 | delta: "hi", |
| 129 | 130 | }, |
| 130 | 131 | }); |
| 132 | + expect((event2?.seq ?? 0) > (event1?.seq ?? 0)).toBe(true); | |
| 131 | 133 | |
| 132 | − service.ingestProviderEvent({ | |
| 134 | + const event3 = service.ingestProviderEvent({ | |
| 133 | 135 | ...baseEvent, |
| 134 | 136 | id: "evt-3", |
| 135 | 137 | createdAt: iso(30), |
| @@ -142,8 +144,9 @@ describe("PersistenceService", () => { · 6 unmodified lines | ||
| 142 | 144 | }, |
| 143 | 145 | }, |
| 144 | 146 | }); |
| 147 | + expect((event3?.seq ?? 0) > (event2?.seq ?? 0)).toBe(true); | |
| 145 | 148 | |
| 146 | − service.ingestProviderEvent({ | |
| 149 | + const event4 = service.ingestProviderEvent({ | |
| 147 | 150 | ...baseEvent, |
| 148 | 151 | id: "evt-4", |
| 149 | 152 | createdAt: iso(40), |
| @@ -156,6 +159,7 @@ describe("PersistenceService", () => { · 6 unmodified lines | ||
| 156 | 159 | }, |
| 157 | 160 | }, |
| 158 | 161 | }); |
| 162 | + expect((event4?.seq ?? 0) > (event3?.seq ?? 0)).toBe(true); | |
| 159 | 163 | |
| 160 | 164 | const snapshot = service.loadSnapshot(); |
| 161 | 165 | expect(snapshot.projects).toHaveLength(1); |
| @@ -179,6 +183,24 @@ describe("PersistenceService", () => { · 17 unmodified lines | ||
| 179 | 183 | expect(catchUp.events.some((event) => event.eventType === "project.upsert")).toBe(true); |
| 180 | 184 | expect(catchUp.events.some((event) => event.eventType === "thread.upsert")).toBe(true); |
| 181 | 185 | 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"]); | |
| 182 | 204 | } finally { |
| 183 | 205 | service.close(); |
| 184 | 206 | } |
| @@ -16,6 +16,8 @@ import { · 15 unmodified lines | ||
| 16 | 16 | type ProjectRemoveInput, |
| 17 | 17 | type ProjectUpdateScriptsInput, |
| 18 | 18 | type ProjectUpdateScriptsResult, |
| 19 | + type ProviderCatchUpInput, | |
| 20 | + type ProviderCatchUpResult, | |
| 19 | 21 | type ProviderEvent, |
| 20 | 22 | type ProviderSendTurnInput, |
| 21 | 23 | type StateBootstrapResult, |
| @@ -44,6 +46,9 @@ import { · 22 unmodified lines | ||
| 44 | 46 | projectRemoveInputSchema, |
| 45 | 47 | projectScriptsSchema, |
| 46 | 48 | projectUpdateScriptsInputSchema, |
| 49 | + providerCatchUpInputSchema, | |
| 50 | + providerCatchUpResultSchema, | |
| 51 | + providerEventSchema, | |
| 47 | 52 | stateBootstrapResultSchema, |
| 48 | 53 | stateCatchUpInputSchema, |
| 49 | 54 | stateCatchUpResultSchema, |
| @@ -94,6 +99,25 @@ interface StateEventRow { · 44 unmodified lines | ||
| 94 | 99 | interface ProviderEventInsertResult { |
| 95 | 100 | inserted: boolean; |
| 96 | 101 | 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; | |
| 97 | 121 | } |
| 98 | 122 | |
| 99 | 123 | export interface PersistenceServiceOptions { |
| @@ -772,6 +796,43 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 672 unmodified lines | ||
| 772 | 796 | }); |
| 773 | 797 | } |
| 774 | 798 | |
| 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 | + | |
| 775 | 836 | listMessages(raw: StateListMessagesInput): StateListMessagesResult { |
| 776 | 837 | const input = stateListMessagesInputSchema.parse(raw); |
| 777 | 838 | const rows = this.db |
| @@ -867,12 +928,18 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 89 unmodified lines | ||
| 867 | 928 | }); |
| 868 | 929 | } |
| 869 | 930 | |
| 870 | − ingestProviderEvent(event: ProviderEvent): void { | |
| 931 | + ingestProviderEvent(event: ProviderEvent): ProviderEvent | null { | |
| 932 | + let persistedEvent: ProviderEvent | null = null; | |
| 871 | 933 | this.withTransaction((pendingEvents) => { |
| 872 | 934 | const insertResult = this.insertProviderEvent(event); |
| 873 | 935 | if (!insertResult.inserted) { |
| 874 | 936 | return; |
| 875 | 937 | } |
| 938 | + persistedEvent = providerEventSchema.parse({ | |
| 939 | + ...event, | |
| 940 | + seq: insertResult.seq, | |
| 941 | + ...(insertResult.runtimeThreadId ? { threadId: insertResult.runtimeThreadId } : {}), | |
| 942 | + }); | |
| 876 | 943 | |
| 877 | 944 | const localThreadId = this.resolveThreadIdForEvent(event, insertResult.runtimeThreadId); |
| 878 | 945 | if (!localThreadId) { |
| @@ -1138,6 +1205,7 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 259 unmodified lines | ||
| 1138 | 1205 | } |
| 1139 | 1206 | } |
| 1140 | 1207 | }); |
| 1208 | + return persistedEvent; | |
| 1141 | 1209 | } |
| 1142 | 1210 | |
| 1143 | 1211 | persistTurnDiffSummaryFromCheckpoint(input: { |
| @@ -1360,6 +1428,13 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 216 unmodified lines | ||
| 1360 | 1428 | return row?.seq ?? 0; |
| 1361 | 1429 | } |
| 1362 | 1430 | |
| 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 | + | |
| 1363 | 1438 | private withTransaction<T>(fn: (pendingEvents: StateEvent[]) => T): T { |
| 1364 | 1439 | const pendingEvents: StateEvent[] = []; |
| 1365 | 1440 | const result = this.stateDb.transaction(() => fn(pendingEvents)); |
| @@ -1766,13 +1841,40 @@ export class PersistenceService extends EventEmitter<PersistenceServiceEvents> { · 400 unmodified lines | ||
| 1766 | 1841 | event.message ?? null, |
| 1767 | 1842 | payloadJson, |
| 1768 | 1843 | event.createdAt, |
| 1769 | − ) as { changes?: number | bigint }; | |
| 1844 | + ) as { changes?: number | bigint; lastInsertRowid?: number | bigint }; | |
| 1845 | + const inserted = toSafeInteger(result.changes, 0) > 0; | |
| 1770 | 1846 | return { |
| 1771 | − inserted: toSafeInteger(result.changes, 0) > 0, | |
| 1847 | + inserted, | |
| 1772 | 1848 | runtimeThreadId: runtimeThreadId ?? null, |
| 1849 | + seq: inserted ? toSafeInteger(result.lastInsertRowid, 0) : 0, | |
| 1773 | 1850 | }; |
| 1774 | 1851 | } |
| 1775 | 1852 | |
| 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 | + | |
| 1776 | 1878 | private parseJson<T>(json: string, schema: SafeParseSchema<T>): T | null { |
| 1777 | 1879 | const candidate = this.tryParseJson(json); |
| 1778 | 1880 | const parsed = schema.safeParse(candidate); |
| @@ -3,6 +3,58 @@ import { describe, expect, it, vi } from "vitest"; · 2 unmodified lines | ||
| 3 | 3 | import { ProviderManager } from "./providerManager"; |
| 4 | 4 | |
| 5 | 5 | 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 | + | |
| 6 | 58 | it("detaches provider event listener and ends thread log streams on dispose", () => { |
| 7 | 59 | const manager = new ProviderManager(); |
| 8 | 60 | const internals = manager as unknown as { |
| @@ -180,18 +180,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 179 unmodified lines | ||
| 180 | 180 | if (this.disposed) { |
| 181 | 181 | return; |
| 182 | 182 | } |
| 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; | |
| 192 | 186 | } |
| 193 | − this.emit("event", event); | |
| 194 | − this.maybeCaptureFilesystemCheckpoint(event); | |
| 187 | + this.maybeCaptureFilesystemCheckpoint(emittedEvent); | |
| 195 | 188 | }; |
| 196 | 189 | |
| 197 | 190 | constructor(options: ProviderManagerOptions = {}) { |
| @@ -546,7 +539,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 348 unmodified lines | ||
| 546 | 539 | } |
| 547 | 540 | |
| 548 | 541 | private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void { |
| 549 | − this.emit("event", { | |
| 542 | + this.publishProviderEvent({ | |
| 550 | 543 | id: randomUUID(), |
| 551 | 544 | kind: "notification", |
| 552 | 545 | provider: "codex", |
| @@ -566,7 +559,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 13 unmodified lines | ||
| 566 | 559 | message: string, |
| 567 | 560 | threadId?: string, |
| 568 | 561 | ): void { |
| 569 | − this.emit("event", { | |
| 562 | + this.publishProviderEvent({ | |
| 570 | 563 | id: randomUUID(), |
| 571 | 564 | kind: "error", |
| 572 | 565 | provider: "codex", |
| @@ -578,6 +571,26 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 5 unmodified lines | ||
| 578 | 571 | }); |
| 579 | 572 | } |
| 580 | 573 | |
| 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 | + | |
| 581 | 594 | private withFilesystemLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> { |
| 582 | 595 | const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve(); |
| 583 | 596 | const next = previous.catch(() => undefined).then(fn); |
| @@ -669,6 +669,58 @@ describe("WebSocket Server", () => { · 668 unmodified lines | ||
| 669 | 669 | expect(response.result).toEqual([]); |
| 670 | 670 | }); |
| 671 | 671 | |
| 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 | + | |
| 672 | 724 | it("persists app settings through appSettings RPC methods", async () => { |
| 673 | 725 | const stateDir = makeTempDir("t3code-ws-app-settings-"); |
| 674 | 726 | server = createTestServer({ cwd: "/test", stateDir }); |
| @@ -334,6 +334,9 @@ export function createServer(options: ServerOptions) { · 333 unmodified lines | ||
| 334 | 334 | case WS_METHODS.providersListSessions: |
| 335 | 335 | return providerManager.listSessions(); |
| 336 | 336 | |
| 337 | + case WS_METHODS.providersCatchUp: | |
| 338 | + return persistenceService.providerCatchUp(request.params as never); | |
| 339 | + | |
| 337 | 340 | case WS_METHODS.providersListCheckpoints: |
| 338 | 341 | return providerManager.listCheckpoints(request.params as never); |
| 339 | 342 | |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | } from "@tanstack/react-router"; |
| 7 | 7 | import { useEffect, useRef } from "react"; |
| 8 | 8 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 9 | +import type { ProviderEvent } from "@t3tools/contracts"; | |
| 9 | 10 | |
| 10 | 11 | import { APP_DISPLAY_NAME } from "../branding"; |
| 11 | 12 | import { Button } from "../components/ui/button"; |
| @@ -120,6 +121,14 @@ function errorDetails(error: unknown): string { · 108 unmodified lines | ||
| 120 | 121 | } |
| 121 | 122 | } |
| 122 | 123 | |
| 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 | + | |
| 123 | 132 | function StateSyncRouter() { |
| 124 | 133 | const api = useNativeApi(); |
| 125 | 134 | const { dispatch } = useStore(); |
| @@ -131,6 +140,8 @@ function StateSyncRouter() { · 5 unmodified lines | ||
| 131 | 140 | }); |
| 132 | 141 | const lastStateSeqRef = useRef(0); |
| 133 | 142 | const stateQueueRef = useRef(Promise.resolve()); |
| 143 | + const lastProviderSeqRef = useRef(0); | |
| 144 | + const providerQueueRef = useRef(Promise.resolve()); | |
| 134 | 145 | |
| 135 | 146 | useEffect(() => { |
| 136 | 147 | if (!api) return; |
| @@ -202,7 +213,7 @@ function StateSyncRouter() { · 65 unmodified lines | ||
| 202 | 213 | |
| 203 | 214 | useEffect(() => { |
| 204 | 215 | if (!api) return; |
| 205 | − return api.providers.onEvent((event) => { | |
| 216 | + const applyProviderEvent = (event: ProviderEvent) => { | |
| 206 | 217 | if (event.method === "turn/completed") { |
| 207 | 218 | void invalidateGitQueries(queryClient); |
| 208 | 219 | } |
| @@ -217,14 +228,73 @@ function StateSyncRouter() { · 8 unmodified lines | ||
| 217 | 228 | }, |
| 218 | 229 | }); |
| 219 | 230 | } |
| 220 | − if (!activeThreadId) return; | |
| 221 | 231 | dispatch({ |
| 222 | 232 | type: "APPLY_EVENT", |
| 223 | 233 | event, |
| 224 | 234 | activeAssistantItemRef, |
| 225 | 235 | activeThreadId, |
| 226 | 236 | }); |
| 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 | + }); | |
| 227 | 271 | }); |
| 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 | + }; | |
| 228 | 298 | }, [activeThreadId, api, dispatch, queryClient]); |
| 229 | 299 | |
| 230 | 300 | useEffect(() => { |
| @@ -93,6 +93,7 @@ export function createWsNativeApi(): NativeApi { · 92 unmodified lines | ||
| 93 | 93 | listSessions: () => transport.request(WS_METHODS.providersListSessions), |
| 94 | 94 | listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), |
| 95 | 95 | getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), |
| 96 | + catchUp: (input) => transport.request(WS_METHODS.providersCatchUp, input), | |
| 96 | 97 | revertToCheckpoint: (input) => |
| 97 | 98 | transport.request(WS_METHODS.providersRevertToCheckpoint, input), |
| 98 | 99 | onEvent: (callback) => |
| @@ -17,6 +17,8 @@ import type { · 16 unmodified lines | ||
| 17 | 17 | GitStatusResult, |
| 18 | 18 | } from "./git"; |
| 19 | 19 | import type { |
| 20 | + ProviderCatchUpInput, | |
| 21 | + ProviderCatchUpResult, | |
| 20 | 22 | ProviderEvent, |
| 21 | 23 | ProviderGetCheckpointDiffInput, |
| 22 | 24 | ProviderGetCheckpointDiffResult, |
| @@ -118,6 +120,7 @@ export interface NativeApi { · 95 unmodified lines | ||
| 118 | 120 | getCheckpointDiff: ( |
| 119 | 121 | input: ProviderGetCheckpointDiffInput, |
| 120 | 122 | ) => Promise<ProviderGetCheckpointDiffResult>; |
| 123 | + catchUp: (input: ProviderCatchUpInput) => Promise<ProviderCatchUpResult>; | |
| 121 | 124 | revertToCheckpoint: ( |
| 122 | 125 | input: ProviderRevertToCheckpointInput, |
| 123 | 126 | ) => Promise<ProviderRevertToCheckpointResult>; |
| @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 5 | + providerCatchUpInputSchema, | |
| 6 | + providerCatchUpResultSchema, | |
| 5 | 7 | providerCheckpointSchema, |
| 6 | 8 | providerEventSchema, |
| 7 | 9 | providerGetCheckpointDiffInputSchema, |
| @@ -156,6 +158,19 @@ describe("providerEventSchema", () => { · 148 unmodified lines | ||
| 156 | 158 | expect(parsed.method).toBe("item/agentMessage/delta"); |
| 157 | 159 | }); |
| 158 | 160 | |
| 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 | + | |
| 159 | 174 | it("accepts request approval metadata", () => { |
| 160 | 175 | const parsed = providerEventSchema.parse({ |
| 161 | 176 | id: "evt_2", |
| @@ -172,6 +187,33 @@ describe("providerEventSchema", () => { · 10 unmodified lines | ||
| 172 | 187 | }); |
| 173 | 188 | }); |
| 174 | 189 | |
| 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 | + | |
| 175 | 217 | describe("providerRespondToRequestInputSchema", () => { |
| 176 | 218 | it("accepts valid decisions", () => { |
| 177 | 219 | const parsed = providerRespondToRequestInputSchema.parse({ |
| @@ -170,6 +170,11 @@ export const providerGetCheckpointDiffResultSchema = z.object({ · 169 unmodified lines | ||
| 170 | 170 | diff: z.string(), |
| 171 | 171 | }); |
| 172 | 172 | |
| 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 | + | |
| 173 | 178 | export const providerRespondToRequestInputSchema = z.object({ |
| 174 | 179 | sessionId: z.string().min(1), |
| 175 | 180 | requestId: z.string().min(1), |
| @@ -179,6 +184,7 @@ export const providerRespondToRequestInputSchema = z.object({ · 3 unmodified lines | ||
| 179 | 184 | export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]); |
| 180 | 185 | |
| 181 | 186 | export const providerEventSchema = z.object({ |
| 187 | + seq: z.number().int().positive().optional(), | |
| 182 | 188 | id: z.string().min(1), |
| 183 | 189 | kind: providerEventKindSchema, |
| 184 | 190 | provider: providerKindSchema, |
| @@ -195,6 +201,11 @@ export const providerEventSchema = z.object({ · 10 unmodified lines | ||
| 195 | 201 | payload: z.unknown().optional(), |
| 196 | 202 | }); |
| 197 | 203 | |
| 204 | +export const providerCatchUpResultSchema = z.object({ | |
| 205 | + events: z.array(providerEventSchema), | |
| 206 | + lastProviderSeq: z.number().int().min(0), | |
| 207 | +}); | |
| 208 | + | |
| 198 | 209 | export type ProviderKind = z.infer<typeof providerKindSchema>; |
| 199 | 210 | export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>; |
| 200 | 211 | export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>; |
| @@ -219,6 +230,8 @@ export type ProviderRevertToCheckpointResult = z.infer< · 18 unmodified lines | ||
| 219 | 230 | >; |
| 220 | 231 | export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>; |
| 221 | 232 | export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>; |
| 233 | +export type ProviderCatchUpInput = z.input<typeof providerCatchUpInputSchema>; | |
| 234 | +export type ProviderCatchUpResult = z.infer<typeof providerCatchUpResultSchema>; | |
| 222 | 235 | export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>; |
| 223 | 236 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 224 | 237 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |
| @@ -7,4 +7,8 @@ describe("WS thread methods", () => { · 6 unmodified lines | ||
| 7 | 7 | expect(WS_METHODS.threadsUpdateTerminalState).toBe("threads.updateTerminalState"); |
| 8 | 8 | expect("threadsUpdate" in WS_METHODS).toBe(false); |
| 9 | 9 | }); |
| 10 | + | |
| 11 | + it("includes provider catch-up method for durable stream resume", () => { | |
| 12 | + expect(WS_METHODS.providersCatchUp).toBe("providers.catchUp"); | |
| 13 | + }); | |
| 10 | 14 | }); |
| @@ -28,6 +28,7 @@ export const WS_METHODS = { · 27 unmodified lines | ||
| 28 | 28 | providersListSessions: "providers.listSessions", |
| 29 | 29 | providersListCheckpoints: "providers.listCheckpoints", |
| 30 | 30 | providersGetCheckpointDiff: "providers.getCheckpointDiff", |
| 31 | + providersCatchUp: "providers.catchUp", | |
| 31 | 32 | providersRevertToCheckpoint: "providers.revertToCheckpoint", |
| 32 | 33 | |
| 33 | 34 | // Project registry methods |