summaryrefslogtreecommitdiffhomepage
path: root/packages/core
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 18:53:42 +0900
committerAdam Malczewski <[email protected]>2026-05-30 18:53:42 +0900
commit2228691e14be2368394e38e600bfa2ce227487b1 (patch)
treed6b3cfffd11dc9f7eec8c88f5fc97a7ec81e61a0 /packages/core
parent497b397e873f96d6fde3d8a44b3318e1ee1cbef4 (diff)
downloaddispatch-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')
-rw-r--r--packages/core/src/agent/agent.tsbin48635 -> 54066 bytes
-rw-r--r--packages/core/src/chunks/append.ts7
-rw-r--r--packages/core/src/credentials/anthropic-betas.ts24
-rw-r--r--packages/core/src/credentials/claude.ts21
-rw-r--r--packages/core/src/llm/anthropic-oauth-transform.ts153
-rw-r--r--packages/core/src/llm/provider.ts44
-rw-r--r--packages/core/src/types/index.ts18
-rw-r--r--packages/core/tests/agent/agent.test.ts286
-rw-r--r--packages/core/tests/llm/anthropic-oauth-transform.test.ts137
-rw-r--r--packages/core/tests/llm/provider.test.ts88
-rw-r--r--packages/core/tests/tools/summon.test.ts6
11 files changed, 765 insertions, 19 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 82b98df..439b436 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
Binary files differ
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
index fe384bd..baccd10 100644
--- a/packages/core/src/chunks/append.ts
+++ b/packages/core/src/chunks/append.ts
@@ -35,9 +35,9 @@
* (no unsealed thinking chunk) are dropped.
*
* Ignored events:
- * - `status`, `done`, `task-list-update`, `tab-created`, `message-queued`,
- * `message-consumed`, `message-cancelled` — these are control / lifecycle
- * events, not message content.
+ * - `status`, `done`, `usage`, `task-list-update`, `tab-created`,
+ * `message-queued`, `message-consumed`, `message-cancelled` — these are
+ * control / lifecycle events, not message content.
*/
import type {
@@ -201,6 +201,7 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
// Lifecycle / control events — no chunk emitted.
case "status":
case "done":
+ case "usage":
case "task-list-update":
case "tab-created":
case "message-queued":
diff --git a/packages/core/src/credentials/anthropic-betas.ts b/packages/core/src/credentials/anthropic-betas.ts
new file mode 100644
index 0000000..f2ca7a6
--- /dev/null
+++ b/packages/core/src/credentials/anthropic-betas.ts
@@ -0,0 +1,24 @@
+// ─── Anthropic Beta Headers ───────────────────────────────────
+//
+// The set of `anthropic-beta` features the official Claude Code CLI sends on
+// every request. Kept in a dependency-free module (no DB / `bun:sqlite`
+// import) so the LLM provider layer (`llm/provider.ts`) can pull the list in
+// without dragging the whole credentials/DB stack into its import graph.
+//
+// `prompt-caching-scope-2026-01-05` is load-bearing for cost: without it the
+// Anthropic API silently ignores every `cache_control` breakpoint we place,
+// producing a 0% cache hit rate (see claude-report.md). `oauth-2025-04-20`
+// gates the Bearer/OAuth flow used by Claude Pro/Max subscriptions.
+
+const BASE_BETAS = [
+ "claude-code-20250219",
+ "oauth-2025-04-20",
+ "interleaved-thinking-2025-05-14",
+ "prompt-caching-scope-2026-01-05",
+ "context-management-2025-06-27",
+ "advisor-tool-2026-03-01",
+];
+
+export function getAnthropicBetas(): string[] {
+ return [...BASE_BETAS];
+}
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 6018207..168d544 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -10,8 +10,14 @@ import {
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { getDatabase } from "../db/index.js";
+import { getAnthropicBetas } from "./anthropic-betas.js";
import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js";
+// Re-exported for backward compatibility — `getAnthropicBetas` historically
+// lived here and is surfaced through `credentials/index.ts`. The definition
+// now lives in the dependency-free `anthropic-betas.ts` module.
+export { getAnthropicBetas };
+
export interface ClaudeCredentials {
accessToken: string;
refreshToken: string;
@@ -367,21 +373,6 @@ export function buildBillingHeaderValue(
export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-// ─── Anthropic Beta Headers ───────────────────────────────────
-
-const BASE_BETAS = [
- "claude-code-20250219",
- "oauth-2025-04-20",
- "interleaved-thinking-2025-05-14",
- "prompt-caching-scope-2026-01-05",
- "context-management-2025-06-27",
- "advisor-tool-2026-03-01",
-];
-
-export function getAnthropicBetas(): string[] {
- return [...BASE_BETAS];
-}
-
// ─── Anthropic Request Headers ────────────────────────────────
export function getAnthropicHeaders(accessToken: string): Record<string, string> {
diff --git a/packages/core/src/llm/anthropic-oauth-transform.ts b/packages/core/src/llm/anthropic-oauth-transform.ts
new file mode 100644
index 0000000..467a307
--- /dev/null
+++ b/packages/core/src/llm/anthropic-oauth-transform.ts
@@ -0,0 +1,153 @@
+/**
+ * Wire-level request restructuring for the Claude OAuth (Pro/Max) flow.
+ *
+ * Anthropic validates the `system` array on OAuth-authenticated, Claude-Code-
+ * billed requests. A genuine Claude Code request looks like:
+ *
+ * system: [
+ * { type: "text", text: "x-anthropic-billing-header: ..." }, // system[0], NO cache_control
+ * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.",
+ * cache_control: { type: "ephemeral" } }, // identity, separate block
+ * ]
+ * messages: [ { role: "user", content: "<the real system prompt>\n\n<user text>" }, ... ]
+ *
+ * i.e. ONLY the billing header and the verbatim identity string may live in
+ * `system[]`. Any third-party system prompt (Dispatch's tool/agent instructions)
+ * MUST be relocated into the first user message. When third-party content stays
+ * in `system[]` next to the identity, Anthropic bills it as premium "extra
+ * usage" (token burn) and refuses to apply the Claude Code prompt-cache scope —
+ * producing the 0% cache hit rate and ballooning cost we were seeing.
+ *
+ * Dispatch builds its system prompt as ONE concatenated block
+ * (`<billing>\n<identity>\n\n<systemPrompt>`); `@ai-sdk/anthropic` serializes
+ * that into a single `system[]` text entry. This transform runs at fetch time
+ * on the already-serialized JSON body and reshapes it into the structure above.
+ *
+ * Mirrors `references/opencode-claude-auth/src/transforms.ts` (`transformBody`),
+ * adapted to Dispatch: tool names are already PascalCase-`mcp_`-prefixed by the
+ * agent, so this transform leaves tools and messages (other than the relocation)
+ * untouched. It is defensive: any parse/shape surprise returns the body
+ * unchanged so a transform bug can never break a request.
+ */
+
+const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
+const BILLING_PREFIX = "x-anthropic-billing-header";
+
+type SystemBlock = { type: "text"; text: string; cache_control?: unknown } & Record<
+ string,
+ unknown
+>;
+
+interface AnthropicRequestBody {
+ system?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
+ messages?: Array<{
+ role?: string;
+ content?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
+ }>;
+ [key: string]: unknown;
+}
+
+/**
+ * Restructure a serialized Anthropic request body string for the Claude Code
+ * OAuth flow. Returns the (possibly rewritten) body, or the original input
+ * unchanged when it isn't a JSON string we recognize.
+ */
+export function transformClaudeOAuthBody(
+ body: BodyInit | null | undefined,
+): BodyInit | null | undefined {
+ if (typeof body !== "string") return body;
+ let parsed: AnthropicRequestBody;
+ try {
+ parsed = JSON.parse(body) as AnthropicRequestBody;
+ } catch {
+ return body;
+ }
+ if (!parsed || typeof parsed !== "object") return body;
+ try {
+ const changed = restructureSystem(parsed);
+ return changed ? JSON.stringify(parsed) : body;
+ } catch {
+ // Never let a transform bug break a real request.
+ return body;
+ }
+}
+
+/**
+ * In-place restructure of `parsed.system` / `parsed.messages`. Returns true if
+ * anything changed (so the caller knows to re-serialize).
+ */
+function restructureSystem(parsed: AnthropicRequestBody): boolean {
+ const raw = parsed.system;
+ let entries: SystemBlock[];
+ if (typeof raw === "string") {
+ entries = [{ type: "text", text: raw }];
+ } else if (Array.isArray(raw)) {
+ entries = raw.map((e) =>
+ typeof e === "string"
+ ? { type: "text", text: e }
+ : ({ ...e, type: "text", text: typeof e.text === "string" ? e.text : "" } as SystemBlock),
+ );
+ } else {
+ return false; // no system field — nothing to do
+ }
+
+ const combined = entries.map((e) => e.text).join("\n\n");
+
+ // Only act on Claude Code shaped requests (must carry the identity string).
+ if (!combined.includes(SYSTEM_IDENTITY)) return false;
+
+ const hadCacheControl = entries.some((e) => e.cache_control != null);
+
+ // Peel the billing-header line out (it is a single line with no newlines).
+ const lines = combined.split("\n");
+ const billingIdx = lines.findIndex((l) => l.startsWith(BILLING_PREFIX));
+ let billingLine: string | null = null;
+ if (billingIdx !== -1) {
+ billingLine = lines[billingIdx] ?? null;
+ lines.splice(billingIdx, 1);
+ }
+ const afterBilling = lines.join("\n").replace(/^\n+/, "");
+
+ // Split the identity prefix from the rest (Dispatch's real system prompt).
+ let rest = "";
+ if (afterBilling.startsWith(SYSTEM_IDENTITY)) {
+ rest = afterBilling.slice(SYSTEM_IDENTITY.length).replace(/^\n+/, "");
+ } else {
+ // Identity is present but not at the front (unexpected) — still isolate it.
+ rest = afterBilling.replace(SYSTEM_IDENTITY, "").replace(/^\n+/, "");
+ }
+
+ // Rebuild system[]: billing (no cache_control) then identity (cached).
+ const newSystem: SystemBlock[] = [];
+ if (billingLine) newSystem.push({ type: "text", text: billingLine });
+ const identityBlock: SystemBlock = { type: "text", text: SYSTEM_IDENTITY };
+ if (hadCacheControl) identityBlock.cache_control = { type: "ephemeral" };
+ newSystem.push(identityBlock);
+
+ // Relocate the third-party system prompt into the first user message.
+ if (rest.length > 0) {
+ const firstUser = Array.isArray(parsed.messages)
+ ? parsed.messages.find((m) => m.role === "user")
+ : undefined;
+ if (firstUser) {
+ if (typeof firstUser.content === "string") {
+ firstUser.content = `${rest}\n\n${firstUser.content}`;
+ } else if (Array.isArray(firstUser.content)) {
+ firstUser.content.unshift({ type: "text", text: rest });
+ } else {
+ firstUser.content = rest;
+ }
+ } else {
+ // No user message to host it — keep it as a (cached) system block so
+ // the request still carries the instructions.
+ const restBlock: SystemBlock = { type: "text", text: rest };
+ if (hadCacheControl) restBlock.cache_control = { type: "ephemeral" };
+ newSystem.push(restBlock);
+ }
+ }
+
+ parsed.system = newSystem;
+ return true;
+}
+
+export const __test = { restructureSystem, SYSTEM_IDENTITY, BILLING_PREFIX };
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
index 81f7f51..5978196 100644
--- a/packages/core/src/llm/provider.ts
+++ b/packages/core/src/llm/provider.ts
@@ -1,6 +1,10 @@
+import { randomUUID } from "node:crypto";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModelV3 } from "@ai-sdk/provider";
+import type { FetchFunction } from "@ai-sdk/provider-utils";
+import { getAnthropicBetas } from "../credentials/anthropic-betas.js";
+import { transformClaudeOAuthBody } from "./anthropic-oauth-transform.js";
export interface ProviderConfig {
apiKey: string;
@@ -71,12 +75,52 @@ export function createProvider(config: ProviderConfig): ModelFactory {
* (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer`
* (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI
* request headers so the request bills against the user's Claude subscription.
+ *
+ * The `anthropic-beta` header is REQUIRED here. `@ai-sdk/anthropic` only emits
+ * an `anthropic-beta` header for betas it auto-derives from tool definitions
+ * (computer-use, structured-outputs, etc.) — it does NOT add the prompt-caching
+ * or oauth betas on its own. Without `prompt-caching-scope-2026-01-05` the API
+ * silently ignores every `cache_control` breakpoint we attach to messages,
+ * giving a 0% cache hit rate and a massive token burn (see claude-report.md).
+ * The SDK folds any `anthropic-beta` it finds on the provider's config headers
+ * back into its own beta set (via `getBetasFromHeaders`), so the values here
+ * are merged — not overwritten — with any tool-derived betas.
*/
function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory {
+ // Stable per-provider session id — mirrors the Claude Code CLI, which sends
+ // the same `X-Claude-Code-Session-Id` across a session's requests.
+ const sessionId = randomUUID();
+ const baseFetch = globalThis.fetch;
+
+ // Custom fetch that (1) restructures the request body into the genuine
+ // Claude Code system layout — required for Anthropic to bill correctly and
+ // apply the prompt-cache scope (see anthropic-oauth-transform.ts) — and
+ // (2) stamps the Claude Code session/request id headers the real CLI sends.
+ // Cast through `unknown`: `FetchFunction` is `typeof globalThis.fetch`, whose
+ // (Bun) type carries a `preconnect` member a plain wrapper can't satisfy.
+ const oauthFetch = (async (
+ input: Parameters<FetchFunction>[0],
+ init?: Parameters<FetchFunction>[1],
+ ) => {
+ const nextInit: RequestInit = { ...init };
+ if (init?.body != null) {
+ nextInit.body = transformClaudeOAuthBody(init.body) ?? init.body;
+ }
+ const headers = new Headers(init?.headers);
+ headers.set("X-Claude-Code-Session-Id", sessionId);
+ if (!headers.has("x-client-request-id")) {
+ headers.set("x-client-request-id", randomUUID());
+ }
+ nextInit.headers = headers;
+ return baseFetch(input, nextInit);
+ }) as unknown as FetchFunction;
+
const anthropic = createAnthropic({
baseURL: config.baseURL || "https://api.anthropic.com/v1",
authToken: config.claudeCredentials?.accessToken ?? config.apiKey,
+ fetch: oauthFetch,
headers: {
+ "anthropic-beta": getAnthropicBetas().join(","),
"anthropic-dangerous-direct-browser-access": "true",
"x-app": "cli",
"user-agent": "claude-cli/2.1.112 (external, sdk-cli)",
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 4148498..7e7460b 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -136,6 +136,24 @@ export type AgentEvent =
| { type: "tool-call"; toolCall: ToolCall }
| { type: "tool-result"; toolResult: ToolResult }
| { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
+ /**
+ * Per-request token usage, emitted once per LLM round-trip (each
+ * `streamText` step) from the AI SDK `finish` stream event. `inputTokens`
+ * is the TOTAL prompt size including cached tokens; `cacheReadTokens` is
+ * the portion served from Anthropic's prompt cache (a cache HIT) and
+ * `cacheWriteTokens` the portion written to it (a cache seed). The "Cache
+ * Rate" view aggregates these to show the prompt-cache hit rate. Non-
+ * caching providers report zero for the cache fields.
+ */
+ | {
+ type: "usage";
+ usage: {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ };
+ }
| { type: "error"; error: string; statusCode?: number }
| { type: "notice"; message: string }
| { type: "model-changed"; keyId: string; modelId: string }
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 () => {