diff options
Diffstat (limited to 'packages/core/tests/db')
| -rw-r--r-- | packages/core/tests/db/chunks.test.ts | 179 |
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"]); + }); +}); |
