summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/db
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 20:06:31 +0900
committerAdam Malczewski <[email protected]>2026-05-30 20:06:31 +0900
commit0f39b6f78957aacf206012ad2193d9b0c1940c1f (patch)
treeff5f2da8b4f3cdf56cf50d44b8fec75a489ad6fe /packages/core/tests/db
parent8c58a973b0d021689cebad5c0cc6d56956bbc2f6 (diff)
downloaddispatch-0f39b6f78957aacf206012ad2193d9b0c1940c1f.tar.gz
dispatch-0f39b6f78957aacf206012ad2193d9b0c1940c1f.zip
refactor(chunks): append-only chunk log with per-step cache-stable wire
Replace the message-as-container model with a flat, append-only chunk log. - chunks table (id, tab_id, seq, turn_id, step, role, type, data_json): one row per chunk; tool_call (assistant) and tool_result (tool) are SEPARATE rows linked by callId. Message/turn are derived groupings, not stored. - chunks/transform.ts: DB-free explode (Chunk[] -> rows) / group (rows -> messages), shared by backend and the browser frontend. - Cache fix: toModelMessages segments each turn at tool-batch boundaries into stable [assistant, tool] pairs per step, so earlier steps serialize byte-identically across requests (kills the prompt-cache churn). - agent-manager persists a turn's chunks on seal (once), discarding a failed fallback attempt's partial chunks; rebuilds agent history from the log. - GET /messages windows the log by chunk seq then groups; loadMoreMessages merges a turn split across the window boundary by turnId. - One-shot migration drops the legacy messages table and clears tabs; settings/credentials/keys/usage preserved. Full suite green (317 tests); biome, tsc, and svelte-check clean.
Diffstat (limited to 'packages/core/tests/db')
-rw-r--r--packages/core/tests/db/chunks.test.ts179
1 files changed, 179 insertions, 0 deletions
diff --git a/packages/core/tests/db/chunks.test.ts b/packages/core/tests/db/chunks.test.ts
new file mode 100644
index 0000000..fe54628
--- /dev/null
+++ b/packages/core/tests/db/chunks.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, it } from "vitest";
+import { explodeTurn, explodeUserText, groupRowsToMessages } from "../../src/chunks/transform.js";
+import type { Chunk, ChunkRow, ChunkRowDraft } from "../../src/types/index.js";
+
+// These tests cover the pure explode/group transforms — the heart of the flat
+// chunk-log storage model. No DB is required.
+
+/** Promote drafts to rows with synthetic seq/id/createdAt (as appendChunks would). */
+function toRows(drafts: ChunkRowDraft[], tabId = "tab-1", startSeq = 0): ChunkRow[] {
+ return drafts.map((d, i) => ({
+ id: `c${i}`,
+ tabId,
+ seq: startSeq + i,
+ turnId: d.turnId,
+ step: d.step,
+ role: d.role,
+ type: d.type,
+ data: d.data,
+ createdAt: 1000 + i,
+ }));
+}
+
+describe("explodeTurn", () => {
+ it("splits a tool-batch into separate tool_call (assistant) and tool_result (tool) rows", () => {
+ const chunks: Chunk[] = [
+ { type: "thinking", text: "hmm", metadata: { anthropic: { signature: "S" } } },
+ { type: "text", text: "let me read" },
+ {
+ type: "tool-batch",
+ calls: [
+ { id: "a1", name: "read_file", arguments: { path: "x" }, result: "X", isError: false },
+ { id: "a2", name: "read_file", arguments: { path: "y" }, result: "Y", isError: false },
+ ],
+ },
+ ];
+ const drafts = explodeTurn("turn-1", chunks);
+
+ // thinking, text, tool_call×2 (assistant), tool_result×2 (tool)
+ expect(drafts.map((d) => `${d.role}/${d.type}`)).toEqual([
+ "assistant/thinking",
+ "assistant/text",
+ "assistant/tool_call",
+ "assistant/tool_call",
+ "tool/tool_result",
+ "tool/tool_result",
+ ]);
+ // All in the same step (one round-trip).
+ expect(drafts.every((d) => d.step === 0)).toBe(true);
+ expect(drafts.every((d) => d.turnId === "turn-1")).toBe(true);
+ });
+
+ it("increments step after each tool-batch (multi-step turn)", () => {
+ const chunks: Chunk[] = [
+ { type: "text", text: "s0" },
+ { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "r" }] },
+ { type: "text", text: "s1" },
+ { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "r" }] },
+ { type: "text", text: "final" },
+ ];
+ const drafts = explodeTurn("turn-1", chunks);
+ const byStep = (s: number) => drafts.filter((d) => d.step === s).map((d) => d.type);
+ expect(byStep(0)).toEqual(["text", "tool_call", "tool_result"]);
+ expect(byStep(1)).toEqual(["text", "tool_call", "tool_result"]);
+ expect(byStep(2)).toEqual(["text"]); // trailing final-step text, no tool-batch
+ });
+
+ it("omits tool_result rows for calls without a result", () => {
+ const chunks: Chunk[] = [
+ { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {} }] },
+ ];
+ const drafts = explodeTurn("turn-1", chunks);
+ expect(drafts.map((d) => d.type)).toEqual(["tool_call"]);
+ });
+});
+
+describe("groupRowsToMessages (round-trip)", () => {
+ it("reconstructs a user message then an assistant message with a per-step tool-batch", () => {
+ const rows = [
+ ...toRows(explodeUserText("turn-1", "hello"), "tab-1", 0),
+ ...toRows(
+ explodeTurn("turn-1", [
+ { type: "text", text: "reading" },
+ {
+ type: "tool-batch",
+ calls: [
+ {
+ id: "a1",
+ name: "read_file",
+ arguments: { path: "x" },
+ result: "X",
+ isError: false,
+ },
+ ],
+ },
+ { type: "text", text: "done" },
+ ]),
+ "tab-1",
+ 1,
+ ),
+ ];
+
+ const msgs = groupRowsToMessages(rows);
+ expect(msgs.map((m) => m.role)).toEqual(["user", "assistant"]);
+ expect(msgs[0]?.chunks).toEqual([{ type: "text", text: "hello" }]);
+
+ const a = msgs[1];
+ if (!a) throw new Error("no assistant message");
+ // reconstructed: text, tool-batch(step0), text(step1)
+ expect(a.chunks.map((c) => c.type)).toEqual(["text", "tool-batch", "text"]);
+ const batch = a.chunks.find((c) => c.type === "tool-batch");
+ if (batch?.type !== "tool-batch") throw new Error("no batch");
+ expect(batch.calls[0]).toMatchObject({
+ id: "a1",
+ name: "read_file",
+ arguments: { path: "x" },
+ result: "X",
+ isError: false,
+ });
+ });
+
+ it("keeps each step's tool calls in its own tool-batch chunk", () => {
+ const rows = toRows(
+ explodeTurn("turn-1", [
+ { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "ra" }] },
+ { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "rb" }] },
+ ]),
+ );
+ const msgs = groupRowsToMessages(rows);
+ expect(msgs).toHaveLength(1);
+ const batches = msgs[0]?.chunks.filter((c) => c.type === "tool-batch") ?? [];
+ expect(batches).toHaveLength(2);
+ });
+
+ it("round-trips a multi-step assistant turn back to its original chunk shape", () => {
+ const original: Chunk[] = [
+ { type: "thinking", text: "plan", metadata: { anthropic: { signature: "S" } } },
+ { type: "text", text: "step0" },
+ {
+ type: "tool-batch",
+ calls: [
+ { id: "a", name: "read_file", arguments: { path: "p" }, result: "R", isError: false },
+ ],
+ },
+ { type: "text", text: "final" },
+ ];
+ const rows = toRows(explodeTurn("turn-1", original));
+ const msgs = groupRowsToMessages(rows);
+ expect(msgs).toHaveLength(1);
+ expect(msgs[0]?.chunks).toEqual(original);
+ });
+
+ it("tolerates an orphan tool_result whose tool_call was paged out", () => {
+ const rows = toRows([
+ {
+ turnId: "turn-1",
+ step: 0,
+ role: "tool",
+ type: "tool_result",
+ data: { callId: "z", name: "t", result: "R", isError: false },
+ },
+ ]);
+ const msgs = groupRowsToMessages(rows);
+ expect(msgs).toHaveLength(1);
+ const batch = msgs[0]?.chunks[0];
+ if (batch?.type !== "tool-batch") throw new Error("no batch");
+ expect(batch.calls[0]).toMatchObject({ id: "z", result: "R" });
+ });
+
+ it("breaks the assistant grouping on a user or system row", () => {
+ const rows = [
+ ...toRows(explodeUserText("t1", "q1"), "tab", 0),
+ ...toRows(explodeTurn("t1", [{ type: "text", text: "a1" }]), "tab", 1),
+ ...toRows(explodeUserText("t2", "q2"), "tab", 2),
+ ...toRows(explodeTurn("t2", [{ type: "system", kind: "notice", text: "n" }]), "tab", 3),
+ ];
+ const msgs = groupRowsToMessages(rows);
+ expect(msgs.map((m) => m.role)).toEqual(["user", "assistant", "user", "system"]);
+ });
+});