diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 02:31:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 02:31:38 +0900 |
| commit | 2686cf6bcd247d17528922e36aa380a521c964e0 (patch) | |
| tree | d49a0a59e0025e6b3dad9dc40d691a7a4a728c04 /src/app | |
| parent | de421a9720bc00334c2cac2913067079a410f8b4 (diff) | |
| download | dispatch-web-2686cf6bcd247d17528922e36aa380a521c964e0.tar.gz dispatch-web-2686cf6bcd247d17528922e36aa380a521c964e0.zip | |
fix(review-3.6): invalidate history caches by epoch and coalesce syncs
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.test.ts | 8 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 48 | ||||
| -rw-r--r-- | src/app/store.test.ts | 36 |
3 files changed, 68 insertions, 24 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 26f5cdb..183b8b4 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -104,7 +104,9 @@ function fakeFetchImpl(): typeof fetch { { status: 200 }, ); } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), { + status: 200, + }); }; } @@ -564,7 +566,9 @@ describe("App component interaction tests", () => { status: 200, }); } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), { + status: 200, + }); }; const ws = fakeSocket(); diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index c354e11..2eb13ef 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -704,7 +704,16 @@ function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySy if (!res.ok) { throw new Error(`History sync failed: ${res.status}`); } - return (await res.json()) as ConversationHistoryResponse; + const data = (await res.json()) as ConversationHistoryResponse; + if ( + !data || + !Array.isArray(data.chunks) || + !Number.isSafeInteger(data.historyEpoch) || + data.historyEpoch < 0 + ) { + throw new Error("Invalid history response"); + } + return data; }; } @@ -1483,6 +1492,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (!res.ok) return; const data = (await res.json()) as ConversationListResponse; + if (!data || !Array.isArray(data.conversations)) { + reportError( + "Failed to load open conversations", + new Error("Invalid conversations response"), + ); + return; + } // Update the status map from the authoritative backend list. const newStatuses = new Map<string, ConversationStatus>(); for (const conv of data.conversations) { @@ -1628,27 +1644,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, onConversationCompacted(msg: ConversationCompactedMessage): void { - // Compaction keeps the conversation ID — the old full history is forked - // to an archive (newConversationId). Just reload the same conversation's - // history (dispose stale store + cache + re-fetch). const cid = msg.conversationId; - const wasActive = tabsStore.activeConversationId === cid; - void cache.delete(cid); - if (!chatStores.has(cid) || !loadedConversationIds.delete(cid)) return; - const store = chatStores.get(cid); - if (store !== undefined) { - store.dispose(); - } - const tab = tabsStore.tabs.find((t) => t.conversationId === cid); - const fresh = createChatFor( - cid, - tab?.model ?? activeModel, - tab?.workspaceId ?? activeWorkspaceId, - ); - chatStores.set(cid, fresh); - ensureLoaded(cid); - if (wasActive) { - refreshActiveChat(); + const store = chatStores.get(cid) ?? watchStores.get(cid); + if (store && loadedConversationIds.has(cid)) { + store.resync(msg.historyEpoch); + } else { + store?.invalidateHistory(msg.historyEpoch); + // Unseen conversations stay lazy. Invalidation is queued before their + // next load; epoch-guarded commits reject any older in-flight response. + void cache + .reset(cid, msg.historyEpoch) + .catch((err) => reportError("Failed to reset conversation cache", err)); } }, onReopen() { diff --git a/src/app/store.test.ts b/src/app/store.test.ts index f5d4723..f257f3a 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -195,7 +195,8 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { } if (/^\/conversations\/[^/]+$/.test(pathname) && searchParams.has("sinceSeq")) { return Response.json( - history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse), + history[url] ?? + ({ chunks: [], historyEpoch: 0, latestSeq: 0 } satisfies ConversationHistoryResponse), ); } } @@ -682,6 +683,7 @@ describe("createAppStore", () => { { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, ], + historyEpoch: 0, latestSeq: 2, }; const base = fakeFetchImpl(); @@ -2743,6 +2745,7 @@ describe("conversation load-on-demand", () => { { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hi" } }, { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "hello" } }, ], + historyEpoch: 0, latestSeq: 2, }); } @@ -2907,12 +2910,21 @@ describe("conversation load-on-demand", () => { const { store, requests } = await setup(ws); try { ws.resolveOpen(); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: "lazy-1", turnId: "old" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "text-delta", conversationId: "lazy-1", turnId: "old", delta: "stale" }, + }); for (const id of ["lazy-1", "unknown", "lazy-0"]) { ws.feedServerMessage({ type: "conversation.compacted", conversationId: id, newConversationId: `${id}-archive`, messagesSummarized: 10, + historyEpoch: 0, messagesKept: 2, }); } @@ -2957,3 +2969,25 @@ describe("conversation load-on-demand", () => { } }); }); + +it.each([ + null, + {}, + { conversations: {} }, +])("reports malformed conversation lists: %j", async (body) => { + const ws = fakeSocket(); + const base = fakeFetchImpl(); + const store = createAppStore({ + socketFactory: () => ws, + indexedDB: new IDBFactory(), + localStorage: createFakeStorage(), + fetchImpl: async (input, init) => + String(input).includes("/conversations?status=") ? Response.json(body) : base(input, init), + }); + try { + ws.resolveOpen(); + await vi.waitFor(() => expect(store.fatalError).toContain("Invalid conversations response")); + } finally { + store.dispose(); + } +}); |
