summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 02:31:38 +0900
committerAdam Malczewski <[email protected]>2026-10-21 02:31:38 +0900
commit2686cf6bcd247d17528922e36aa380a521c964e0 (patch)
treed49a0a59e0025e6b3dad9dc40d691a7a4a728c04 /src/app
parentde421a9720bc00334c2cac2913067079a410f8b4 (diff)
downloaddispatch-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.ts8
-rw-r--r--src/app/store.svelte.ts48
-rw-r--r--src/app/store.test.ts36
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();
+ }
+});