diff options
| author | Adam Malczewski <[email protected]> | 2026-05-30 18:53:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-30 18:53:42 +0900 |
| commit | 2228691e14be2368394e38e600bfa2ce227487b1 (patch) | |
| tree | d6b3cfffd11dc9f7eec8c88f5fc97a7ec81e61a0 /packages/core/tests | |
| parent | 497b397e873f96d6fde3d8a44b3318e1ee1cbef4 (diff) | |
| download | dispatch-2228691e14be2368394e38e600bfa2ce227487b1.tar.gz dispatch-2228691e14be2368394e38e600bfa2ce227487b1.zip | |
feat(cache): Anthropic prompt caching, usage telemetry, and Cache Rate view
- send prompt-caching + oauth anthropic-beta headers on the Claude OAuth provider
- restructure the OAuth request body (billing header, identity split, relocate
third-party system prompt to the first user message) to match Claude Code
- apply rolling cache_control breakpoints and group a turn's tool results into a
single role:tool message for correct breakpoint placement
- emit per-step usage events (cache read/write split) and add the Cache Rate
sidebar panel
- dedup byte-identical tool calls within a single batch
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 286 | ||||
| -rw-r--r-- | packages/core/tests/llm/anthropic-oauth-transform.test.ts | 137 | ||||
| -rw-r--r-- | packages/core/tests/llm/provider.test.ts | 88 | ||||
| -rw-r--r-- | packages/core/tests/tools/summon.test.ts | 6 |
4 files changed, 516 insertions, 1 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index e0a999d..d6daac6 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -1028,4 +1028,290 @@ describe("Agent", () => { const rc = assistantMsg?.providerOptions?.openaiCompatible?.reasoning_content; expect(rc).toBeUndefined(); }); + + // ─── Prompt-caching: tool-result grouping & breakpoints (claude-report.md) ── + + it("groups a turn's tool results into a SINGLE role:'tool' message (Root Cause 2)", async () => { + // The agent batches three distinct read_file calls in one step. The + // rebuilt ModelMessage[] must contain exactly ONE `role: "tool"` message + // holding all three results (not three separate tool messages). Per- + // result messages would strand the rolling cache breakpoints on the last + // two adjacent tool results, wasting a breakpoint. + 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)}`, + }; + vi.mocked(streamText) + .mockReturnValueOnce( + makeMockStreamResult([ + { type: "tool-call", toolCallId: "b1", toolName: "read_file", input: { path: "a.txt" } }, + { type: "tool-call", toolCallId: "b2", toolName: "read_file", input: { path: "b.txt" } }, + { type: "tool-call", toolCallId: "b3", toolName: "read_file", input: { path: "c.txt" } }, + finishToolCalls, + ]), + ) + .mockReturnValueOnce( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]), + ); + + const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] })); + for await (const _ of agent.run("read three files")) { + /* consume */ + } + + // Inspect the step-1 request (sent after the batch executed) — its tail + // is the assistant tool-calls + the grouped tool results. + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; + + const toolMsgs = messages.filter((m) => m.role === "tool"); + expect(toolMsgs).toHaveLength(1); + const toolContent = toolMsgs[0]?.content as Array<Record<string, unknown>>; + expect(toolContent).toHaveLength(3); + expect(toolContent.every((p) => p.type === "tool-result")).toBe(true); + // IDs preserved per result. + expect(toolContent.map((p) => p.toolCallId)).toEqual(["b1", "b2", "b3"]); + }); + + it("places cache breakpoints on [assistant, grouped-tool], not adjacent tool results (Root Cause 2)", async () => { + // With grouping, the last two non-system messages of a mid-turn request + // are [assistant(tool-calls), tool(all results)]. Both — plus the system + // message — must carry an ephemeral cacheControl marker. The pre-fix bug + // put both rolling breakpoints on two adjacent tool-result messages and + // never marked the assistant turn. + 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)}`, + }; + vi.mocked(streamText) + .mockReturnValueOnce( + makeMockStreamResult([ + { type: "tool-call", toolCallId: "c1", toolName: "read_file", input: { path: "a.txt" } }, + { type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "b.txt" } }, + { type: "tool-call", toolCallId: "c3", toolName: "read_file", input: { path: "c.txt" } }, + finishToolCalls, + ]), + ) + .mockReturnValueOnce( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]), + ); + + const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] })); + for await (const _ of agent.run("read three files")) { + /* consume */ + } + + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ + role: string; + content: unknown; + providerOptions?: { anthropic?: { cacheControl?: { type?: string } } }; + }>; + + const isCached = (m?: { + providerOptions?: { anthropic?: { cacheControl?: { type?: string } } }; + }) => m?.providerOptions?.anthropic?.cacheControl?.type === "ephemeral"; + + // Exactly one tool message — no adjacent tool-result breakpoints. + expect(messages.filter((m) => m.role === "tool")).toHaveLength(1); + + const systemMsg = messages.find((m) => m.role === "system"); + const assistantMsg = messages.find((m) => m.role === "assistant"); + const toolMsg = messages.find((m) => m.role === "tool"); + + expect(isCached(systemMsg)).toBe(true); + expect(isCached(assistantMsg)).toBe(true); + expect(isCached(toolMsg)).toBe(true); + }); + + it("does NOT attach cacheControl for the openai-compatible (non-Anthropic) path", async () => { + // Sanity check: caching markers are Anthropic-only. The OpenAI-compatible + // endpoints do automatic server-side prefix caching and reject explicit + // cache_control, so no marker should be attached. + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]), + ); + const agent = new Agent(makeConfig()); // default → openai-compatible + for await (const _ of agent.run("hello")) { + /* consume */ + } + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ + role: string; + providerOptions?: { anthropic?: { cacheControl?: unknown } }; + }>; + for (const m of messages) { + expect(m.providerOptions?.anthropic?.cacheControl).toBeUndefined(); + } + }); + + // ─── Tool-call dedup (tool-runner-duplication-incident.md) ───────────────── + + it("deduplicates byte-identical tool calls within a single batch", async () => { + // Claude can degenerate and emit the same tool call (same name + args) + // many times in one batch. Each copy keeps its own id (and still gets its + // own result), but the tool must execute only ONCE — re-running identical + // idempotent reads wastes time/money and floods the context. + let execCount = 0; + const toolDef = { + name: "read_file", + description: "reads a file", + parameters: z.object({ path: z.string() }), + execute: async (args: Record<string, unknown>) => { + execCount++; + return `contents of ${String(args.path)}`; + }, + }; + vi.mocked(streamText) + .mockReturnValueOnce( + makeMockStreamResult([ + { + type: "tool-call", + toolCallId: "d1", + toolName: "read_file", + input: { path: "package.json" }, + }, + { + type: "tool-call", + toolCallId: "d2", + toolName: "read_file", + input: { path: "package.json" }, + }, + { + type: "tool-call", + toolCallId: "d3", + toolName: "read_file", + input: { path: "package.json" }, + }, + finishToolCalls, + ]), + ) + .mockReturnValueOnce( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]), + ); + + const agent = new Agent(makeConfig({ tools: [toolDef] })); + const events: AgentEvent[] = []; + for await (const e of agent.run("read it thrice")) { + events.push(e); + } + + // Executed exactly once despite three identical calls. + expect(execCount).toBe(1); + + // Every call id still received its own result, all with identical content. + const results = events.filter( + (e): e is Extract<AgentEvent, { type: "tool-result" }> => e.type === "tool-result", + ); + expect(results).toHaveLength(3); + expect(results.map((e) => e.toolResult.toolCallId).sort()).toEqual(["d1", "d2", "d3"]); + for (const r of results) { + expect(r.toolResult.result).toBe("contents of package.json"); + } + }); + + it("does NOT deduplicate tool calls with differing arguments", async () => { + // Dedup is keyed on name + serialized arguments. Distinct args must each + // execute — only byte-identical calls collapse. + let execCount = 0; + const toolDef = { + name: "read_file", + description: "reads a file", + parameters: z.object({ path: z.string() }), + execute: async (args: Record<string, unknown>) => { + execCount++; + return `contents of ${String(args.path)}`; + }, + }; + vi.mocked(streamText) + .mockReturnValueOnce( + makeMockStreamResult([ + { type: "tool-call", toolCallId: "e1", toolName: "read_file", input: { path: "a.txt" } }, + { type: "tool-call", toolCallId: "e2", toolName: "read_file", input: { path: "b.txt" } }, + { type: "tool-call", toolCallId: "e3", toolName: "read_file", input: { path: "a.txt" } }, + finishToolCalls, + ]), + ) + .mockReturnValueOnce( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]), + ); + + const agent = new Agent(makeConfig({ tools: [toolDef] })); + for await (const _ of agent.run("read a, b, a")) { + /* consume */ + } + + // a.txt + b.txt are distinct → two executions; the repeated a.txt reuses + // the first result. + expect(execCount).toBe(2); + }); + + // ─── Usage / cache-rate telemetry ────────────────────────────────────────── + + it("emits a usage event from the finish-step part with the cache read/write split", async () => { + // The per-step `usage` (with Anthropic's cache read/write split in + // `inputTokenDetails`) rides on the `finish-step` part — NOT the terminal + // `finish` part, which only carries the aggregate `totalUsage`. The agent + // re-emits it as a `usage` AgentEvent that powers the Cache Rate view. + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([ + { type: "text-delta", id: "t0", text: "hi" }, + { + type: "finish-step", + finishReason: "stop", + rawFinishReason: "stop", + usage: { + inputTokens: 1000, + outputTokens: 50, + inputTokenDetails: { + noCacheTokens: 200, + cacheReadTokens: 750, + cacheWriteTokens: 50, + }, + }, + }, + finishStop, + ]), + ); + + const agent = new Agent(makeConfig()); + const events: AgentEvent[] = []; + for await (const e of agent.run("hi")) { + events.push(e); + } + + const usageEvents = events.filter( + (e): e is Extract<AgentEvent, { type: "usage" }> => e.type === "usage", + ); + // Exactly one usage event (from finish-step) — the terminal `finish` + // part must NOT double-count. + expect(usageEvents).toHaveLength(1); + expect(usageEvents[0]?.usage).toEqual({ + inputTokens: 1000, + outputTokens: 50, + cacheReadTokens: 750, + cacheWriteTokens: 50, + }); + }); + + it("does NOT emit a usage event when no finish-step usage is present", async () => { + // `finishStop` (type `finish`, aggregate `totalUsage` only) must not + // trigger a usage event — and with no `finish-step` part there is no + // per-step usage to emit. + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + const events: AgentEvent[] = []; + for await (const e of agent.run("hi")) { + events.push(e); + } + + expect(events.some((e) => e.type === "usage")).toBe(false); + }); }); diff --git a/packages/core/tests/llm/anthropic-oauth-transform.test.ts b/packages/core/tests/llm/anthropic-oauth-transform.test.ts new file mode 100644 index 0000000..a8bb156 --- /dev/null +++ b/packages/core/tests/llm/anthropic-oauth-transform.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { transformClaudeOAuthBody } from "../../src/llm/anthropic-oauth-transform.js"; + +const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; +const BILLING = + "x-anthropic-billing-header: cc_version=2.1.112.abc; cc_entrypoint=sdk-cli; cch=12345;"; + +interface WireBody { + system?: Array<{ type: string; text: string; cache_control?: unknown }>; + messages?: Array<{ role: string; content: unknown }>; +} + +/** + * Build the body shape Dispatch produces: ONE system block holding + * `<billing>\n<identity>\n\n<systemPrompt>`, marked with cache_control by + * `applyAnthropicCaching`. + */ +function dispatchBody(systemPrompt: string, firstUser = "hello"): string { + return JSON.stringify({ + model: "claude-opus-4-8", + system: [ + { + type: "text", + text: `${BILLING}\n${IDENTITY}\n\n${systemPrompt}`, + cache_control: { type: "ephemeral" }, + }, + ], + messages: [{ role: "user", content: firstUser }], + }); +} + +function parse(out: BodyInit | null | undefined): WireBody { + return JSON.parse(out as string) as WireBody; +} + +describe("transformClaudeOAuthBody", () => { + it("isolates the billing header as system[0] WITHOUT cache_control", () => { + const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools."))); + const sys = result.system ?? []; + expect(sys[0]?.text).toBe(BILLING); + expect(sys[0]?.cache_control).toBeUndefined(); + }); + + it("keeps the identity as a separate system block and carries the cache_control there", () => { + const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools."))); + const sys = result.system ?? []; + expect(sys[1]?.text).toBe(IDENTITY); + expect(sys[1]?.cache_control).toEqual({ type: "ephemeral" }); + // Only billing + identity remain in system[] — the third-party prompt was moved out. + expect(sys).toHaveLength(2); + }); + + it("relocates the third-party system prompt into the first user message", () => { + const result = parse( + transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.", "do the thing")), + ); + const sys = result.system ?? []; + // The system prompt must NOT appear anywhere in system[]. + expect(sys.some((b) => b.text.includes("You are Dispatch"))).toBe(false); + // It is prepended to the first user message. + expect(result.messages?.[0]?.content).toBe("You are Dispatch. Use tools.\n\ndo the thing"); + }); + + it("prepends a text block when the first user message uses array content", () => { + const body = JSON.stringify({ + system: [ + { + type: "text", + text: `${BILLING}\n${IDENTITY}\n\nDispatch instructions here.`, + cache_control: { type: "ephemeral" }, + }, + ], + messages: [{ role: "user", content: [{ type: "text", text: "user text" }] }], + }); + const result = parse(transformClaudeOAuthBody(body)); + const content = result.messages?.[0]?.content as Array<{ type: string; text: string }>; + expect(content[0]).toEqual({ type: "text", text: "Dispatch instructions here." }); + expect(content[1]).toEqual({ type: "text", text: "user text" }); + }); + + it("does not carry cache_control to the identity when the source had none", () => { + const body = JSON.stringify({ + system: [{ type: "text", text: `${BILLING}\n${IDENTITY}\n\nInstr.` }], + messages: [{ role: "user", content: "hi" }], + }); + const result = parse(transformClaudeOAuthBody(body)); + expect(result.system?.[1]?.cache_control).toBeUndefined(); + }); + + it("keeps the prompt as a cached system block when there is no user message", () => { + const body = JSON.stringify({ + system: [ + { + type: "text", + text: `${BILLING}\n${IDENTITY}\n\nInstr only.`, + cache_control: { type: "ephemeral" }, + }, + ], + messages: [], + }); + const result = parse(transformClaudeOAuthBody(body)); + const sys = result.system ?? []; + expect(sys[0]?.text).toBe(BILLING); + expect(sys[1]?.text).toBe(IDENTITY); + expect(sys[2]?.text).toBe("Instr only."); + expect(sys[2]?.cache_control).toEqual({ type: "ephemeral" }); + }); + + it("leaves non-Claude-Code bodies (no identity string) untouched", () => { + const body = JSON.stringify({ + system: [{ type: "text", text: "Some unrelated system prompt." }], + messages: [{ role: "user", content: "hi" }], + }); + // Returned unchanged (same reference string, byte-identical). + expect(transformClaudeOAuthBody(body)).toBe(body); + }); + + it("returns non-string bodies unchanged", () => { + const buf = new Uint8Array([1, 2, 3]); + expect(transformClaudeOAuthBody(buf)).toBe(buf); + expect(transformClaudeOAuthBody(undefined)).toBeUndefined(); + expect(transformClaudeOAuthBody(null)).toBeNull(); + }); + + it("returns invalid JSON unchanged", () => { + const garbage = "{not json"; + expect(transformClaudeOAuthBody(garbage)).toBe(garbage); + }); + + it("never emits more than the 4 cache_control breakpoints Anthropic allows", () => { + const result = parse(transformClaudeOAuthBody(dispatchBody("Big system prompt."))); + const all = result.system ?? []; + const cacheBlocks = all.filter((b) => b.cache_control != null); + // Only the identity block is marked here — well under the limit of 4. + expect(cacheBlocks.length).toBeLessThanOrEqual(4); + }); +}); diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts index 6171e6b..9e6b2ad 100644 --- a/packages/core/tests/llm/provider.test.ts +++ b/packages/core/tests/llm/provider.test.ts @@ -118,6 +118,94 @@ describe("createClaudeOAuthProvider", () => { expect(callArgs.headers?.["user-agent"]).toMatch(/claude-cli/); }); + it("installs a fetch wrapper that restructures the body and stamps Claude Code session headers", async () => { + mockCreateAnthropic.mockClear(); + + // Capture what global fetch receives after the wrapper runs. + const globalFetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + const prevFetch = globalThis.fetch; + globalThis.fetch = globalFetchMock as unknown as typeof fetch; + try { + createProvider({ + provider: "anthropic", + apiKey: "test-key", + baseURL: "", + claudeCredentials: { accessToken: "tok" }, + })("claude-opus-4-8"); + + const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as { fetch?: typeof fetch }; + expect(typeof callArgs.fetch).toBe("function"); + + const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; + const BILLING = + "x-anthropic-billing-header: cc_version=2.1.112.x; cc_entrypoint=sdk-cli; cch=abcde;"; + const body = JSON.stringify({ + system: [ + { + type: "text", + text: `${BILLING}\n${IDENTITY}\n\nDispatch system prompt.`, + cache_control: { type: "ephemeral" }, + }, + ], + messages: [{ role: "user", content: "hi there" }], + }); + + await callArgs.fetch?.("https://api.anthropic.com/v1/messages", { + method: "POST", + body, + headers: { "content-type": "application/json" }, + }); + + expect(globalFetchMock).toHaveBeenCalledOnce(); + const [, init] = globalFetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + + // Body was restructured: billing isolated, third-party prompt moved to user msg. + const sent = JSON.parse(init.body as string) as { + system: Array<{ text: string; cache_control?: unknown }>; + messages: Array<{ content: string }>; + }; + expect(sent.system).toHaveLength(2); + expect(sent.system[0]?.text).toBe(BILLING); + expect(sent.system[0]?.cache_control).toBeUndefined(); + expect(sent.system[1]?.text).toBe(IDENTITY); + expect(sent.messages[0]?.content).toBe("Dispatch system prompt.\n\nhi there"); + + // Claude Code session headers were stamped. + const headers = new Headers(init.headers); + expect(headers.get("X-Claude-Code-Session-Id")).toBeTruthy(); + expect(headers.get("x-client-request-id")).toBeTruthy(); + } finally { + globalThis.fetch = prevFetch; + } + }); + + it("sends the anthropic-beta header so prompt-caching is honored (claude-report.md Root Cause 1)", () => { + // Without `anthropic-beta: ...,prompt-caching-scope-2026-01-05,...` the + // Anthropic API silently ignores every `cache_control` marker we attach + // to messages, producing a 0% cache hit rate. `@ai-sdk/anthropic` does + // NOT inject this beta on its own — it only derives betas from tool + // definitions — so the OAuth provider MUST set it on its config headers. + mockCreateAnthropic.mockClear(); + + createProvider({ + provider: "anthropic", + apiKey: "test-key", + baseURL: "", + claudeCredentials: { accessToken: "tok" }, + })("claude-opus-4-5"); + + const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record< + string, + Record<string, string> + >; + const betaHeader = callArgs.headers?.["anthropic-beta"]; + expect(betaHeader).toBeDefined(); + const betas = (betaHeader ?? "").split(",").map((b) => b.trim()); + // The load-bearing caching + oauth betas must be present. + expect(betas).toContain("prompt-caching-scope-2026-01-05"); + expect(betas).toContain("oauth-2025-04-20"); + }); + it("uses default Anthropic baseURL when none provided", () => { mockCreateAnthropic.mockClear(); diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts index 3909e48..722748c 100644 --- a/packages/core/tests/tools/summon.test.ts +++ b/packages/core/tests/tools/summon.test.ts @@ -118,7 +118,11 @@ describe("createSummonTool — execute() argument forwarding", () => { [], ); const out = await tool.execute({ task: "x" }); - expect(out).toBe("child-output"); + // Foreground summons prefix the blocked result with `agent_id: <id>` so + // the frontend's ToolCallDisplay regex can surface the "Open Tab" button + // (see summon.ts). Assert both the prefix and the child output survive. + expect(out).toContain("agent_id: id-1"); + expect(out).toBe("agent_id: id-1\n\nchild-output"); }); it("surfaces child errors when blocking", async () => { |
