summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 23:14:55 +0900
committerAdam Malczewski <[email protected]>2026-05-30 23:14:55 +0900
commit624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47 (patch)
tree869d34092345344ff13953398f876c8b38c8116a /packages/frontend/tests
parentb19f1aafc43141a865ecd40a813ed3212e77d95e (diff)
downloaddispatch-624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47.tar.gz
dispatch-624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47.zip
feat(chunks): chunk-native frontend store with turn-sealed reconcile + per-chunk eviction
Replace the stored ChatMessage[] with a chunk-native model: tab.chunks (sealed ChunkRow[]) + tab.live (transient in-flight turn buffer) + derived tab.renderGroups. This enables per-chunk eviction (trimming WITHIN a large turn) and raw-chunk pagination (loadOlderChunks), removing the whole-message eviction limitation. Backend: - Emit turn-start/turn-sealed around each turn; expose currentTurnId in the status snapshot. turn-sealed fires after the durable write (status:idle fires before it). - New GET /tabs/:id/chunks raw paginated endpoint (limit/before). - Wrap appendChunks in a single SQLite transaction. Frontend: - turn-sealed drives a turn-aware reconcile that folds the sealed turn into chunks while preserving a concurrent newer in-flight turn and pending queued messages; deferred while the user is scrolled up. - Stable turn-scoped render keys (${turnId}:${role}:${n}) avoid remount/flash. Reconcile correctness (three review passes): - preserve a concurrent newer turn when an earlier deferred reconcile flushes; - keep optimistic queued user messages (no loss); - turn-start backfill skips pending queued rows and tags only the turn initiator; - bind consumed interrupt messages to the in-flight turn so they collapse on seal (no lingering/duplicated bubble). Tests: chat-store reconcile/eviction/pagination suite; api chunks endpoint + events.
Diffstat (limited to 'packages/frontend/tests')
-rw-r--r--packages/frontend/tests/chat-store.test.ts483
1 files changed, 448 insertions, 35 deletions
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index c485fc7..b9d37f2 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -71,6 +71,7 @@ beforeEach(() => {
);
});
+import { appSettings } from "../src/lib/settings.svelte.js";
import { createTabStore } from "../src/lib/tabs.svelte.js";
import type { Chunk, PermissionPrompt } from "../src/lib/types.js";
import { wsClient } from "../src/lib/ws.svelte.js";
@@ -92,17 +93,34 @@ async function setupStoreWithTab() {
*/
function getAssistantChunks(store: ReturnType<typeof createTabStore>): Chunk[] | undefined {
const tab = store.tabs[0];
- const assistant = tab?.messages.find((m) => m.role === "assistant");
+ const assistant = tab?.renderGroups.find((m) => m.role === "assistant");
return assistant?.chunks;
}
+/**
+ * Build a raw `ChunkRow` as the `GET /tabs/:id/chunks` endpoint returns them.
+ * The chunk-native store loads/paginates raw rows and groups them for render.
+ */
+function chunkRow(
+ id: string,
+ tabId: string,
+ seq: number,
+ turnId: string,
+ role: "user" | "assistant" | "tool" | "system",
+ type: "text" | "thinking" | "tool_call" | "tool_result" | "error" | "system",
+ data: unknown,
+ step = 0,
+): Record<string, unknown> {
+ return { id, tabId, seq, turnId, step, role, type, data, createdAt: seq };
+}
+
describe("tabStore — streaming chunk flow (real $state)", () => {
it("text-delta creates a streaming assistant message and appends deltas", async () => {
const { store, tabId } = await setupStoreWithTab();
store.handleEvent({ type: "text-delta", delta: "Hello", tabId });
- const assistant = store.tabs[0]?.messages.find((m) => m.role === "assistant");
+ const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
expect(assistant).toBeDefined();
expect(assistant?.isStreaming).toBe(true);
expect(assistant?.chunks).toEqual([{ type: "text", text: "Hello" }]);
@@ -204,7 +222,7 @@ describe("tabStore — streaming chunk flow (real $state)", () => {
message: { role: "assistant", chunks: [] },
tabId,
} as Parameters<typeof store.handleEvent>[0]);
- const assistant = store.tabs[0]?.messages.find((m) => m.role === "assistant");
+ const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
expect(assistant?.chunks).toEqual([{ type: "text", text: "partial" }]);
expect(assistant?.isStreaming).toBe(false);
expect(store.tabs[0]?.currentAssistantId).toBeNull();
@@ -315,7 +333,8 @@ describe("tabStore — streaming chunk flow (real $state)", () => {
it("error event with no in-flight turn opens a fresh assistant message", async () => {
const { store, tabId } = await setupStoreWithTab();
store.handleEvent({ type: "error", error: "boom", tabId });
- const assistantMessages = store.tabs[0]?.messages.filter((m) => m.role === "assistant") ?? [];
+ const assistantMessages =
+ store.tabs[0]?.renderGroups.filter((m) => m.role === "assistant") ?? [];
expect(assistantMessages.length).toBeGreaterThanOrEqual(1);
const errChunks = assistantMessages[assistantMessages.length - 1]?.chunks;
expect(errChunks).toHaveLength(1);
@@ -342,7 +361,7 @@ describe("tabStore — streaming chunk flow (real $state)", () => {
it("notice with no turn in flight creates a role:system message", async () => {
const { store, tabId } = await setupStoreWithTab();
store.handleEvent({ type: "notice", message: "standalone", tabId });
- const systemMsg = store.tabs[0]?.messages.find((m) => m.role === "system");
+ const systemMsg = store.tabs[0]?.renderGroups.find((m) => m.role === "system");
expect(systemMsg).toBeDefined();
const chunks = systemMsg?.chunks;
expect(chunks).toHaveLength(1);
@@ -355,7 +374,7 @@ describe("tabStore — streaming chunk flow (real $state)", () => {
const { store, tabId } = await setupStoreWithTab();
store.handleEvent({ type: "notice", message: "first", tabId });
store.handleEvent({ type: "notice", message: "second", tabId });
- const sysMsgs = store.tabs[0]?.messages.filter((m) => m.role === "system") ?? [];
+ const sysMsgs = store.tabs[0]?.renderGroups.filter((m) => m.role === "system") ?? [];
expect(sysMsgs).toHaveLength(1);
expect(sysMsgs[0]?.chunks).toHaveLength(2);
});
@@ -455,7 +474,7 @@ describe("tabStore — reactivity contract", () => {
expect(store.tabs[0]?.agentStatus).toBe("idle");
expect(store.tabs[0]?.currentAssistantId).toBeNull();
// The streaming flag on the in-flight message should be cleared.
- const assistant = store.tabs[0]?.messages.find((m) => m.role === "assistant");
+ const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
expect(assistant?.isStreaming).toBe(false);
});
});
@@ -702,26 +721,24 @@ describe("hydrateFromBackend", () => {
json: () => Promise.resolve({ statuses: {} }),
});
}
- if (url.split("?")[0]?.endsWith("/tabs/t1/messages")) {
+ if (url.split("?")[0]?.endsWith("/tabs/t1/chunks")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
- messages: [
- { id: "m1", role: "user", chunks: [{ type: "text", text: "hello" }] },
- {
- id: "m2",
- role: "assistant",
- chunks: [{ type: "text", text: "hi back" }],
- },
+ chunks: [
+ chunkRow("m1", "t1", 0, "u1", "user", "text", { text: "hello" }),
+ chunkRow("m2", "t1", 1, "u1", "assistant", "text", { text: "hi back" }),
],
+ total: 2,
+ oldestSeq: 0,
}),
});
}
- if (url.split("?")[0]?.endsWith("/tabs/t2/messages")) {
+ if (url.split("?")[0]?.endsWith("/tabs/t2/chunks")) {
return Promise.resolve({
ok: true,
- json: () => Promise.resolve({ messages: [] }),
+ json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
});
}
return Promise.reject(new Error(`unexpected fetch ${url}`));
@@ -733,9 +750,9 @@ describe("hydrateFromBackend", () => {
expect(n).toBe(2);
expect(store.tabs.length).toBe(2);
expect(store.tabs[0]?.id).toBe("t1");
- expect(store.tabs[0]?.messages.length).toBe(2);
+ expect(store.tabs[0]?.renderGroups.length).toBe(2);
expect(store.tabs[1]?.id).toBe("t2");
- expect(store.tabs[1]?.messages.length).toBe(0);
+ expect(store.tabs[1]?.renderGroups.length).toBe(0);
expect(store.activeTabId).toBe("t1");
});
@@ -772,12 +789,14 @@ describe("hydrateFromBackend", () => {
}),
});
}
- if (url.split("?")[0]?.endsWith("/tabs/tr/messages")) {
+ if (url.split("?")[0]?.endsWith("/tabs/tr/chunks")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
- messages: [{ id: "u1", role: "user", chunks: [{ type: "text", text: "go" }] }],
+ chunks: [chunkRow("u1", "tr", 0, "turn-r", "user", "text", { text: "go" })],
+ total: 1,
+ oldestSeq: 0,
}),
});
}
@@ -792,8 +811,8 @@ describe("hydrateFromBackend", () => {
expect(tab?.agentStatus).toBe("running");
expect(tab?.currentAssistantId).toBe("live-msg-id");
// Two messages: the user message + the seeded in-flight assistant.
- expect(tab?.messages.length).toBe(2);
- const inflight = tab?.messages.find((m) => m.id === "live-msg-id");
+ expect(tab?.renderGroups.length).toBe(2);
+ const inflight = tab?.renderGroups.find((m) => m.id === "live-msg-id");
expect(inflight).toBeDefined();
expect(inflight?.isStreaming).toBe(true);
expect(inflight?.chunks).toEqual([
@@ -882,8 +901,11 @@ describe("hydrateFromBackend", () => {
if (url.endsWith("/status")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
}
- if (url.split("?")[0]?.endsWith("/tabs/ti/messages")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) });
+ if (url.split("?")[0]?.endsWith("/tabs/ti/chunks")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
+ });
}
return Promise.reject(new Error(`unexpected fetch ${url}`));
}),
@@ -939,20 +961,22 @@ describe("hydrateFromBackend", () => {
if (url.endsWith("/status")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
}
- if (url.split("?")[0]?.endsWith("/tabs/tA/messages")) {
+ if (url.split("?")[0]?.endsWith("/tabs/tA/chunks")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
- messages: [{ id: "msg-a", role: "user", chunks: [{ type: "text", text: "ok" }] }],
+ chunks: [chunkRow("msg-a", "tA", 0, "turn-a", "user", "text", { text: "ok" })],
+ total: 1,
+ oldestSeq: 0,
}),
});
}
- if (url.split("?")[0]?.endsWith("/tabs/tB/messages")) {
+ if (url.split("?")[0]?.endsWith("/tabs/tB/chunks")) {
// HTTP error path: response is not ok.
return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
}
- if (url.split("?")[0]?.endsWith("/tabs/tC/messages")) {
+ if (url.split("?")[0]?.endsWith("/tabs/tC/chunks")) {
// Network error path: the fetch itself rejects.
return Promise.reject(new Error("simulated network failure"));
}
@@ -966,19 +990,19 @@ describe("hydrateFromBackend", () => {
// Healthy tab restored with its message.
const tA = store.tabs.find((t) => t.id === "tA");
- expect(tA?.messages.length).toBe(1);
- expect(tA?.messages[0]?.chunks).toEqual([{ type: "text", text: "ok" }]);
+ expect(tA?.renderGroups.length).toBe(1);
+ expect(tA?.renderGroups[0]?.chunks).toEqual([{ type: "text", text: "ok" }]);
// Both broken tabs restored with empty message lists — neither
// crashed the hydration nor leaked an error chunk into the UI.
const tB = store.tabs.find((t) => t.id === "tB");
expect(tB).toBeDefined();
- expect(tB?.messages.length).toBe(0);
+ expect(tB?.renderGroups.length).toBe(0);
expect(tB?.agentStatus).toBe("idle");
const tC = store.tabs.find((t) => t.id === "tC");
expect(tC).toBeDefined();
- expect(tC?.messages.length).toBe(0);
+ expect(tC?.renderGroups.length).toBe(0);
expect(tC?.agentStatus).toBe("idle");
});
});
@@ -1013,7 +1037,7 @@ describe("handleEvent statuses with TabStatusSnapshot", () => {
const tab = store.tabs.find((t) => t.id === tabId);
expect(tab?.agentStatus).toBe("running");
expect(tab?.currentAssistantId).toBe("live-x");
- const inflight = tab?.messages.find((m) => m.id === "live-x");
+ const inflight = tab?.renderGroups.find((m) => m.id === "live-x");
expect(inflight).toBeDefined();
expect(inflight?.chunks).toEqual([{ type: "text", text: "live data" }]);
expect(inflight?.isStreaming).toBe(true);
@@ -1046,7 +1070,7 @@ describe("handleEvent statuses with TabStatusSnapshot", () => {
const tab = store.tabs.find((t) => t.id === tabId);
expect(tab?.agentStatus).toBe("idle");
expect(tab?.currentAssistantId).toBeNull();
- const msgA = tab?.messages.find((m) => m.id === "msg-a");
+ const msgA = tab?.renderGroups.find((m) => m.id === "msg-a");
expect(msgA?.isStreaming).toBe(false);
});
});
@@ -1097,3 +1121,392 @@ describe("tabStore — cache rate (usage events)", () => {
expect(store.tabs[0]?.cacheStats).toBeUndefined();
});
});
+
+// ─── chunk-native store: eviction, pagination, reconcile ────────
+//
+// The store's source of truth for HISTORY is a flat ChunkRow[] (`tab.chunks`,
+// real seqs); the live turn is a transient tail (`tab.live`) folded from
+// stream deltas and reconciled into `chunks` on `turn-sealed`. `tab.renderGroups`
+// is a derived render projection. These tests drive the real store.
+
+describe("tabStore — chunk-native eviction / pagination / reconcile", () => {
+ function chunksResponse(rows: Array<Record<string, unknown>>, total?: number) {
+ const oldestSeq = rows.length > 0 ? ((rows[0]?.seq as number) ?? null) : null;
+ return {
+ ok: true,
+ json: () => Promise.resolve({ chunks: rows, total: total ?? rows.length, oldestSeq }),
+ };
+ }
+ function tabsListResponse(id: string) {
+ return {
+ ok: true,
+ json: () => Promise.resolve({ tabs: [{ id, title: id, parentTabId: null }] }),
+ };
+ }
+ function emptyStatuses() {
+ return { ok: true, json: () => Promise.resolve({ statuses: {} }) };
+ }
+ const tick = () => new Promise((r) => setTimeout(r, 0));
+
+ it("evicts sealed chunks to chunkLimit, trimming WITHIN one large turn", async () => {
+ appSettings.chunkLimit = 5;
+ try {
+ // One turn of 10 chunk rows (the pathological single big turn).
+ const rows = Array.from({ length: 10 }, (_, i) =>
+ chunkRow(`c${i}`, "big", i, "turn-1", i === 0 ? "user" : "assistant", "text", {
+ text: `t${i}`,
+ }),
+ );
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) return Promise.resolve(tabsListResponse("big"));
+ if (url.endsWith("/status")) return Promise.resolve(emptyStatuses());
+ if (url.split("?")[0]?.endsWith("/tabs/big/chunks"))
+ return Promise.resolve(chunksResponse(rows, 10));
+ return Promise.reject(new Error(`unexpected ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ await store.hydrateFromBackend();
+ const tab = store.tabs.find((t) => t.id === "big");
+ // Bounded to chunkLimit; the OLDEST rows of the single turn were trimmed.
+ expect(tab?.chunks.length).toBe(5);
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([5, 6, 7, 8, 9]);
+ // Pagination cursor points at a real remaining seq.
+ expect(tab?.oldestLoadedSeq).toBe(5);
+ } finally {
+ appSettings.chunkLimit = 100;
+ }
+ });
+
+ it("loadOlderChunks prepends an older page and dedupes by seq", async () => {
+ appSettings.chunkLimit = 1000; // don't evict during this test
+ try {
+ const newer = [6, 7, 8, 9].map((s) =>
+ chunkRow(`c${s}`, "pg", s, "t", "assistant", "text", { text: `t${s}` }),
+ );
+ // Overlaps the newer window at seq 6 — must dedupe.
+ const older = [2, 3, 4, 5, 6].map((s) =>
+ chunkRow(`c${s}`, "pg", s, "t", "assistant", "text", { text: `t${s}` }),
+ );
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) return Promise.resolve(tabsListResponse("pg"));
+ if (url.endsWith("/status")) return Promise.resolve(emptyStatuses());
+ if (url.split("?")[0]?.endsWith("/tabs/pg/chunks")) {
+ return Promise.resolve(
+ url.includes("before=") ? chunksResponse(older, 8) : chunksResponse(newer, 8),
+ );
+ }
+ return Promise.reject(new Error(`unexpected ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ await store.hydrateFromBackend();
+ let tab = store.tabs.find((t) => t.id === "pg");
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([6, 7, 8, 9]);
+ expect(tab?.oldestLoadedSeq).toBe(6);
+
+ await store.loadOlderChunks("pg");
+ tab = store.tabs.find((t) => t.id === "pg");
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([2, 3, 4, 5, 6, 7, 8, 9]);
+ expect(tab?.oldestLoadedSeq).toBe(2);
+ } finally {
+ appSettings.chunkLimit = 100;
+ }
+ });
+
+ it("turn-sealed folds the live turn into the sealed chunk log with real seqs", async () => {
+ const sealed = [
+ chunkRow("u", "rc", 0, "turn-x", "user", "text", { text: "hi" }),
+ chunkRow("a", "rc", 1, "turn-x", "assistant", "text", { text: "answer" }),
+ ];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.split("?")[0]?.endsWith("/tabs/rc/chunks"))
+ return Promise.resolve(chunksResponse(sealed, 2));
+ return Promise.reject(new Error(`unexpected ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ store.handleEvent({
+ type: "tab-created",
+ id: "rc",
+ title: "RC",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ });
+ store.handleEvent({ type: "turn-start", turnId: "turn-x", tabId: "rc" });
+ store.handleEvent({ type: "text-delta", delta: "answer", tabId: "rc" });
+ // While streaming: live tail holds the in-flight assistant; sealed empty.
+ let tab = store.tabs.find((t) => t.id === "rc");
+ expect(tab?.live.length).toBe(1);
+ expect(tab?.chunks.length).toBe(0);
+ // The live assistant is tagged with the turn id (stable render key basis).
+ expect(tab?.live[0]?.turnId).toBe("turn-x");
+
+ store.handleEvent({ type: "turn-sealed", turnId: "turn-x", tabId: "rc" });
+ await tick(); // reconcile refetch is async
+ tab = store.tabs.find((t) => t.id === "rc");
+ expect(tab?.live.length).toBe(0);
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
+ expect(tab?.renderGroups.map((m) => m.role)).toEqual(["user", "assistant"]);
+ // The sealed messages carry the SAME turn id as the live ones did, so the
+ // turn-scoped render key is stable across reconcile (no remount/flash).
+ expect(tab?.renderGroups.every((m) => m.turnId === "turn-x")).toBe(true);
+ });
+
+ it("defers reconcile while scrolled up, then runs it on return to bottom", async () => {
+ const sealed = [chunkRow("u", "df", 0, "turn-y", "user", "text", { text: "q" })];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.split("?")[0]?.endsWith("/tabs/df/chunks"))
+ return Promise.resolve(chunksResponse(sealed, 1));
+ return Promise.reject(new Error(`unexpected ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ store.handleEvent({
+ type: "tab-created",
+ id: "df",
+ title: "DF",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ });
+ store.handleEvent({ type: "turn-start", turnId: "turn-y", tabId: "df" });
+ store.handleEvent({ type: "text-delta", delta: "partial", tabId: "df" });
+ store.setScrolledUp("df", true);
+ store.handleEvent({ type: "turn-sealed", turnId: "turn-y", tabId: "df" });
+ await tick();
+ // Deferred: live tail still present, not yet folded into sealed chunks.
+ expect(store.tabs.find((t) => t.id === "df")?.live.length).toBe(1);
+
+ store.setScrolledUp("df", false);
+ await tick();
+ const tab = store.tabs.find((t) => t.id === "df");
+ expect(tab?.live.length).toBe(0);
+ expect(tab?.chunks.length).toBe(1);
+ });
+
+ it("preserves an optimistic queued user message when an earlier turn reconciles", async () => {
+ const sealed = [
+ chunkRow("u", "q", 0, "turn-a", "user", "text", { text: "first" }),
+ chunkRow("a", "q", 1, "turn-a", "assistant", "text", { text: "answer" }),
+ ];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.split("?")[0]?.endsWith("/tabs/q/chunks"))
+ return Promise.resolve(chunksResponse(sealed, 2));
+ return Promise.reject(new Error(`unexpected ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ store.handleEvent({
+ type: "tab-created",
+ id: "q",
+ title: "Q",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ });
+ store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "q" });
+ store.handleEvent({ type: "text-delta", delta: "answer", tabId: "q" });
+ // User queues a follow-up WHILE turn-a streams (optimistic, no turn id yet).
+ store.handleEvent({
+ type: "message-queued",
+ tabId: "q",
+ messageId: "q1",
+ message: "do this next",
+ });
+ expect(store.tabs.find((t) => t.id === "q")?.live.some((m) => m.id === "queued-q1")).toBe(true);
+
+ // turn-a seals → reconcile. The queued user bubble must survive.
+ store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "q" });
+ await tick();
+ const tab = store.tabs.find((t) => t.id === "q");
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
+ expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(true);
+ // The sealed turn's assistant was folded out of the live tail.
+ expect(tab?.live.some((m) => m.role === "assistant")).toBe(false);
+ });
+
+ it("turn-start backfill skips a pending queued row (race), tags only the initiator", async () => {
+ // Realistic race: the user sends a prompt (plain optimistic row, no turn id
+ // yet) and, before the WS `turn-start` arrives, sends a follow-up that the
+ // backend queues (`queued-` prefix). When `turn-start` finally lands, the
+ // backfill must tag ONLY the plain initiator and SKIP the pending queued
+ // row — otherwise the queued row inherits the sealing turn's id and is
+ // wiped on reconcile (the Pass-2 Blocker).
+ const store = createTabStore();
+ store.handleEvent({
+ type: "tab-created",
+ id: "rq",
+ title: "RQ",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ });
+ store.switchTab("rq"); // sendMessage targets the active tab
+ const sealed = [
+ chunkRow("u", "rq", 0, "turn-a", "user", "text", { text: "first" }),
+ chunkRow("a", "rq", 1, "turn-a", "assistant", "text", { text: "answer" }),
+ ];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string, opts?: { body?: string }) => {
+ const path = url.split("?")[0] ?? "";
+ if (path.endsWith("/tabs/rq/chunks")) return Promise.resolve(chunksResponse(sealed, 2));
+ if (path.endsWith("/chat")) {
+ // The 2nd send carries a queueId → backend reports it queued.
+ const queued = (opts?.body ?? "").includes("queueId");
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve(queued ? { status: "queued", messageId: "srv" } : { status: "ok" }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({}),
+ text: () => Promise.resolve(""),
+ });
+ }),
+ );
+
+ // A freshly created tab defaults to "running"; the agent is idle here.
+ store.handleEvent({ type: "status", status: "idle", tabId: "rq" });
+ await store.sendMessage("first"); // idle → plain optimistic user row
+ store.handleEvent({ type: "status", status: "running", tabId: "rq" });
+ await store.sendMessage("second"); // running → queued (queued- prefix)
+
+ let tab = store.tabs.find((t) => t.id === "rq");
+ const initiatorId = tab?.live.find((m) => m.role === "user" && !m.id.startsWith("queued-"))?.id;
+ const queuedId = tab?.live.find((m) => m.id.startsWith("queued-"))?.id;
+ expect(initiatorId).toBeTruthy();
+ expect(queuedId).toBeTruthy();
+
+ // turn-start arrives LATE: the queued follow-up is already in the live tail.
+ store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "rq" });
+ tab = store.tabs.find((t) => t.id === "rq");
+ expect(tab?.live.find((m) => m.id === initiatorId)?.turnId).toBe("turn-a");
+ expect(tab?.live.find((m) => m.id === queuedId)?.turnId).toBeUndefined();
+
+ store.handleEvent({ type: "text-delta", delta: "answer", tabId: "rq" });
+ store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "rq" });
+ await tick();
+ tab = store.tabs.find((t) => t.id === "rq");
+ // Initiator folded cleanly into its sealed row (no duplicate user bubble);
+ // the queued follow-up survives, still untagged and still queued.
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
+ expect(tab?.live.find((m) => m.id === queuedId)?.turnId).toBeUndefined();
+ expect(tab?.live.some((m) => m.role === "assistant")).toBe(false);
+ expect(tab?.renderGroups.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
+ });
+
+ it("a consumed interrupt message collapses into the sealed turn (no lingering bubble)", async () => {
+ // During a running turn the user queues a message; the agent CONSUMES it
+ // (interrupt), folding its text into the turn's persisted chunks. The
+ // frontend's consumed user bubble must be BOUND to the in-flight turn so it
+ // is dropped on reconcile — otherwise it lingers untagged forever AND
+ // duplicates the interrupt text now living in the sealed chunk (Pass-3 Block).
+ const sealed = [
+ chunkRow("u", "ic", 0, "turn-a", "user", "text", { text: "do a thing" }),
+ chunkRow("a", "ic", 1, "turn-a", "assistant", "text", { text: "working ...resumed" }),
+ ];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.split("?")[0]?.endsWith("/tabs/ic/chunks"))
+ return Promise.resolve(chunksResponse(sealed, 2));
+ return Promise.reject(new Error(`unexpected ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ store.handleEvent({
+ type: "tab-created",
+ id: "ic",
+ title: "IC",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ });
+ store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "ic" });
+ store.handleEvent({ type: "text-delta", delta: "working", tabId: "ic" });
+ store.handleEvent({ type: "message-queued", tabId: "ic", messageId: "qx", message: "stop" });
+ // Agent consumes the queued message mid-turn (interrupt injection).
+ store.handleEvent({ type: "message-consumed", tabId: "ic", messageIds: ["qx"] });
+ let tab = store.tabs.find((t) => t.id === "ic");
+ const consumed = tab?.live.find((m) => m.id === "qx");
+ expect(consumed).toBeTruthy();
+ // The fix: the consumed row is bound to the active turn, not left untagged.
+ expect(consumed?.turnId).toBe("turn-a");
+ expect(tab?.queuedMessages.some((m) => m.id === "qx")).toBe(false);
+
+ store.handleEvent({ type: "text-delta", delta: "resumed", tabId: "ic" });
+ store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "ic" });
+ await tick();
+ tab = store.tabs.find((t) => t.id === "ic");
+ // Collapsed to the persisted shape: the consumed bubble was dropped; only
+ // the sealed chunks remain (no lingering / duplicated interrupt bubble).
+ expect(tab?.live.length).toBe(0);
+ expect(tab?.live.some((m) => m.id === "qx")).toBe(false);
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
+ });
+
+ it("preserves a concurrent newer turn when an earlier deferred reconcile flushes", async () => {
+ const sealedA = [
+ chunkRow("ua", "c", 0, "turn-a", "user", "text", { text: "A?" }),
+ chunkRow("aa", "c", 1, "turn-a", "assistant", "text", { text: "A!" }),
+ ];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.split("?")[0]?.endsWith("/tabs/c/chunks"))
+ return Promise.resolve(chunksResponse(sealedA, 2));
+ return Promise.reject(new Error(`unexpected ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ store.handleEvent({
+ type: "tab-created",
+ id: "c",
+ title: "C",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ });
+ // Turn A streams; user scrolls up; A finishes → reconcile deferred.
+ store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "c" });
+ store.handleEvent({ type: "text-delta", delta: "A!", tabId: "c" });
+ store.setScrolledUp("c", true);
+ store.handleEvent({ type: "status", status: "idle", tabId: "c" });
+ store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "c" });
+ // Turn B (a queued message) starts streaming while still scrolled up.
+ store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "c" });
+ store.handleEvent({ type: "status", status: "running", tabId: "c" });
+ store.handleEvent({ type: "text-delta", delta: "B in progress", tabId: "c" });
+ let tab = store.tabs.find((t) => t.id === "c");
+ expect(tab?.liveTurnId).toBe("turn-b");
+ const bId = tab?.currentAssistantId;
+ expect(bId).toBeTruthy();
+
+ // User scrolls down → the deferred reconcile for turn-a flushes.
+ store.setScrolledUp("c", false);
+ await tick();
+ tab = store.tabs.find((t) => t.id === "c");
+ // Turn A folded into the sealed log...
+ expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
+ // ...but turn B's in-flight state survived intact (no wipe / no remount).
+ expect(tab?.liveTurnId).toBe("turn-b");
+ expect(tab?.currentAssistantId).toBe(bId);
+ expect(tab?.live.some((m) => m.turnId === "turn-b")).toBe(true);
+ expect(tab?.live.some((m) => m.turnId === "turn-a")).toBe(false);
+ });
+});