summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
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/tests
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/tests')
-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
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 () => {