diff options
| author | Adam Malczewski <[email protected]> | 2026-05-30 20:06:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-30 20:06:31 +0900 |
| commit | 0f39b6f78957aacf206012ad2193d9b0c1940c1f (patch) | |
| tree | ff5f2da8b4f3cdf56cf50d44b8fec75a489ad6fe /packages/core/tests | |
| parent | 8c58a973b0d021689cebad5c0cc6d56956bbc2f6 (diff) | |
| download | dispatch-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')
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 141 | ||||
| -rw-r--r-- | packages/core/tests/db/chunks.test.ts | 179 |
2 files changed, 288 insertions, 32 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index d6daac6..6c2b452 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -450,10 +450,11 @@ describe("Agent", () => { expect(toolContent[0]).not.toHaveProperty("result"); }); - it("Anthropic [tool-call, text] split: mixed-order assistant message gets split into [text]+[tool-call]", async () => { - // Pre-seed an assistant message with chunks in [tool-batch, text] order — - // which produces [tool-call, text] in the ModelMessage content, a shape - // Anthropic rejects. Only applies for anthropic / opencode-anthropic provider. + it("per-step segmentation: a [tool-batch, text] turn becomes [assistant(tool-call), tool(result), assistant(text)]", async () => { + // `toModelMessages` segments a turn at each tool-batch boundary, so the + // tool-batch (step 0) and the trailing text (step 1) land in SEPARATE + // assistant messages — never a single invalid [tool_use, text] block. + // This is the cache-stability fix and is applied for every provider. const agent = new Agent(makeConfig({ provider: "opencode-anthropic" })); agent.messages.push({ role: "user", @@ -490,34 +491,31 @@ describe("Agent", () => { const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; - // After Anthropic structural normalisation, we should have TWO assistant messages: - // 1st: text-only content - // 2nd: tool-call-only content + // No assistant message may mix tool-call and non-tool-call parts (the + // invalid shape Anthropic rejects); segmentation guarantees this. const assistantMsgs = messages.filter((m) => m.role === "assistant"); - expect(assistantMsgs.length).toBeGreaterThanOrEqual(2); - - // Find the text-only assistant message and the tool-call-only assistant message - const textOnlyMsg = assistantMsgs.find((m) => { + for (const m of assistantMsgs) { const c = m.content as Array<Record<string, unknown>>; - return Array.isArray(c) && c.every((p) => p.type !== "tool-call"); - }); - const toolOnlyMsg = assistantMsgs.find((m) => { + if (!Array.isArray(c)) continue; + const hasToolCall = c.some((p) => p.type === "tool-call"); + const hasNonToolCall = c.some((p) => p.type !== "tool-call"); + expect(hasToolCall && hasNonToolCall).toBe(false); + } + + // The seeded turn yields a tool-call assistant message immediately + // followed by its tool-result message (valid tool_use → tool_result). + const toolOnlyIdx = messages.findIndex((m) => { const c = m.content as Array<Record<string, unknown>>; - return Array.isArray(c) && c.every((p) => p.type === "tool-call"); + return m.role === "assistant" && Array.isArray(c) && c.some((p) => p.type === "tool-call"); }); - - // Narrow the optionals — toBeDefined() already verified non-null, - // but TypeScript needs the explicit assertion via local consts so - // we can pass them to indexOf without `!`. - if (!textOnlyMsg || !toolOnlyMsg) throw new Error("type guard"); - - // Text message comes first (before tool-call message) — Anthropic requires this ordering - expect(messages.indexOf(textOnlyMsg)).toBeLessThan(messages.indexOf(toolOnlyMsg)); + expect(toolOnlyIdx).toBeGreaterThanOrEqual(0); + expect(messages[toolOnlyIdx + 1]?.role).toBe("tool"); }); - it("Anthropic [tool-call, text] split: openai-compatible provider preserves original order (no split)", async () => { - // For non-Anthropic providers, the [tool-call, text] split should NOT be applied. - // (No provider set → defaults to openai-compatible) + it("per-step segmentation also applies to the openai-compatible provider", async () => { + // Segmentation is provider-agnostic: a [tool-batch, text] turn is split + // into separate assistant messages for openai-compatible too, with the + // tool result in its own tool message (the standard OpenAI shape). const agent = new Agent(makeConfig()); agent.messages.push({ role: "user", @@ -552,13 +550,24 @@ describe("Agent", () => { const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; - // For openai-compatible provider, only ONE assistant message with mixed content + // The seeded [tool-batch, text] turn is segmented: a tool-call-only + // assistant message, its tool message, and a separate text assistant + // message (the new turn's "ok" reply adds one more). No assistant + // message mixes tool-call and non-tool-call parts. const assistantMsgs = messages.filter((m) => m.role === "assistant"); - expect(assistantMsgs).toHaveLength(1); - const content = assistantMsgs[0]?.content as Array<Record<string, unknown>>; - // Both tool-call and text parts should be in the same message - expect(content.some((p) => p.type === "tool-call")).toBe(true); - expect(content.some((p) => p.type === "text")).toBe(true); + for (const m of assistantMsgs) { + const c = m.content as Array<Record<string, unknown>>; + if (!Array.isArray(c)) continue; + expect(c.some((p) => p.type === "tool-call") && c.some((p) => p.type !== "tool-call")).toBe( + false, + ); + } + expect(messages.some((m) => m.role === "tool")).toBe(true); + const toolCallMsg = assistantMsgs.find((m) => { + const c = m.content as Array<Record<string, unknown>>; + return Array.isArray(c) && c.some((p) => p.type === "tool-call"); + }); + expect(toolCallMsg).toBeDefined(); }); it("empty-text-part filter (Anthropic): empty text chunk is not sent", async () => { @@ -1250,6 +1259,74 @@ describe("Agent", () => { expect(execCount).toBe(2); }); + // ─── Cache stability: per-step wire prefix is immutable ───────────────────── + + it("keeps earlier steps' wire messages byte-identical across requests (cache prefix is stable)", async () => { + // A 3-step tool turn. The messages for steps 0 and 1 must serialize + // identically in the step-2 request and the step-3 request — that + // byte-stability is what lets Anthropic's rolling prompt cache extend + // instead of re-writing the whole prefix every step (cache-miss-report.md). + // Uses the openai-compatible provider so no cacheControl markers (which + // intentionally move each step) obscure the content comparison. + let n = 0; + // mock.calls accumulates across tests in this file — reset so our + // `calls.length` assertions count only this run's requests. + vi.mocked(streamText).mockClear(); + const toolDef = { + name: "read_file", + description: "reads a file", + parameters: z.object({ path: z.string() }), + execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`, + }; + const toolStep = (id: string, path: string) => + makeMockStreamResult([ + { type: "reasoning-delta", id: `r${id}`, text: `thinking ${id}` }, + { type: "text-delta", id: `t${id}`, text: `step ${id}` }, + { type: "tool-call", toolCallId: id, toolName: "read_file", input: { path } }, + finishToolCalls, + ]); + vi.mocked(streamText).mockImplementation(() => { + n++; + if (n === 1) return toolStep("s0", "a.txt"); + if (n === 2) return toolStep("s1", "b.txt"); + if (n === 3) return toolStep("s2", "c.txt"); + return makeMockStreamResult([{ type: "text-delta", id: "tf", text: "done" }, finishStop]); + }); + + const agent = new Agent(makeConfig({ tools: [toolDef] })); + for await (const _ of agent.run("go")) { + /* consume */ + } + + // 4 streamText calls (steps 0..3). Compare the step-2 request (call idx 2) + // and step-3 request (call idx 3). + const calls = vi.mocked(streamText).mock.calls; + expect(calls.length).toBe(4); + const req2 = calls[2]?.[0]?.messages as unknown[]; + const req3 = calls[3]?.[0]?.messages as unknown[]; + + // Step-2 request = [system, user, a(s0), tool(s0), a(s1), tool(s1)] (6). + // Step-3 request appends a(s2), tool(s2). The shared 6-message prefix + // must be byte-identical. + expect(req2).toHaveLength(6); + expect(req3).toHaveLength(8); + expect(JSON.stringify(req3.slice(0, 6))).toBe(JSON.stringify(req2)); + + // And each step really is its own [assistant, tool] pair (not one merged + // assistant message with all tool calls bunched together). + const roles = (req3 as Array<{ role: string }>).map((m) => m.role); + expect(roles).toEqual([ + "system", + "user", + "assistant", + "tool", + "assistant", + "tool", + "assistant", + "tool", + ]); + }); + // ─── Usage / cache-rate telemetry ────────────────────────────────────────── it("emits a usage event from the finish-step part with the cache read/write split", async () => { 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"]); + }); +}); |
