diff --git a/docs/multi-provider-meta-harness.md b/docs/multi-provider-meta-harness.md index 201d9d5..e424ad7 100644 --- a/docs/multi-provider-meta-harness.md +++ b/docs/multi-provider-meta-harness.md @@ -97,8 +97,8 @@ Anthropic │ assistant: { content: [tool_use blocks] } OpenAI │ assistant: { tool_calls: [function call objects] } │ tool: { tool_call_id, content } ───────────┼─────────────────────────────────────────── -Gemini │ model: { parts: [functionCall parts] } - │ user: { parts: [functionResponse parts] } +Gemini │ model: { parts: [functionCall parts] } + │ function: { parts: [functionResponse parts] } ``` Conversion is deterministic. Each provider API validates that tool_result messages reference declared tools. To satisfy this, every provider call includes the full canonical tool definition list regardless of which subset appears in history. diff --git a/docs/providers-pi.md b/docs/providers-pi.md index 8f018cd..fa9f92d 100644 --- a/docs/providers-pi.md +++ b/docs/providers-pi.md @@ -62,6 +62,8 @@ The bridge announces itself on session start; **if it fails to load, turns fail ## Switching backends mid-session `/provider pi` (or `session.set_provider`) swaps a live session's backend while keeping the session id, scrollback, transcript, and identity. -The conversation so far is carried into the incoming backend as a rendered transcript (oldest turns dropped first past ~24k chars) — a faithful transcript, **not** a native continuation: tool_use structures, prompt-cache state, and extension state don't cross. +The conversation so far is carried into the incoming backend as a structured-text transcript — tool calls rendered as structured blocks (name, input JSON, fenced output), oldest turns dropped first past ~24k chars. +Stateless backends (gemini, openai) go further: they replay the history in their native function-call structure on every request. +Warm backends (claude, pi) stay a faithful transcript, **not** a native continuation — neither the Claude Agent SDK nor pi RPC accepts synthesized native-history injection, so prompt-cache state and extension state don't cross. Switching resets the model to the new backend's default, and mid-turn switches are rejected — interrupt first. `/provider claude` brings the session back the same way. diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 00e40e6..7152b49 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -964,10 +964,12 @@ export interface SessionRotateMsg extends BaseClientMsg { * session id, scrollback, transcript, and identity all stay; the backing * agent is torn down and replaced, and the accumulated canonical history is * handed to the incoming provider (`seedFromHistory`) so it can continue - * the conversation. Fidelity contract: the new backend receives a faithful - * TRANSCRIPT (tool calls flattened to text), not a native continuation — - * provider-native structures (tool_use blocks, prompt cache, extension - * state) do not survive the switch. + * the conversation. Fidelity contract: STATELESS backends (gemini, openai) + * replay the history in their native structure (real function-call turns) + * on every subsequent request; WARM backends (claude, pi) receive a + * faithful structured-text transcript prepended to their first prompt — + * neither accepts synthesized native-history injection, so prompt cache + * and extension state still do not survive the switch. * * Rejected with `invalid_request` when the provider is unknown (fail-closed, * same rule as `session.create`) or the session is mid-turn — interrupt diff --git a/src/daemon/providers/canonical.ts b/src/daemon/providers/canonical.ts index bd0b987..edf9904 100644 --- a/src/daemon/providers/canonical.ts +++ b/src/daemon/providers/canonical.ts @@ -2,13 +2,25 @@ * Canonical conversation history — the format codeoid uses internally so * multiple providers can share turns. * - * Phase 1: types + constants + history converters + CanonicalHistoryAccumulator. - * - Tool calls are rendered as inline text for non-Claude providers so they - * see what ran and what it returned, without needing function-calling support. + * Capture (CanonicalHistoryAccumulator) has always been structured: + * CanonicalTurn carries content, thinking, and CanonicalToolCall[] with + * ids/inputs/outputs. Phase 2 (this file's converters) renders that history + * in each provider's NATIVE structure — Anthropic tool_use/tool_result + * blocks, Gemini functionCall/functionResponse parts, OpenAI tool_calls + + * tool-role messages — so a switched session's history reads as real + * tool-call turns, not a narrated summary. * - * Phase 2 (future): replace the inline-text fallback in each converter with - * the provider's native function_call / function_response format. The - * CanonicalToolCall type already captures everything needed. + * Two honest fidelity limits remain: + * - `thinking` is captured but NOT replayed into any provider payload: + * Anthropic requires a cryptographic signature on replayed thinking + * blocks (ours are synthesized, so they'd be rejected — and the API + * ignores prior-turn thinking anyway), and neither Gemini nor OpenAI + * accepts imported reasoning. Display-only by design. + * - A CanonicalTurn flattens an agent loop (text → tool → text …) into + * one turn, so converters emit the parallel-tool-call shape: one + * assistant message with every tool_use, then one message with every + * result. Valid everywhere, but intra-turn interleaving is not + * reconstructed. */ import { type ProviderEvent, isSubagentEvent } from "./interface.js"; @@ -99,35 +111,60 @@ export function limitToolOutput(canonicalName: string, output: string): string { return `${output.slice(0, limit)}\n…output truncated at ${limit} chars (full output was ${output.length} chars)`; } -// ── History converters ──────────────────────────────────────────────────────── +// ── Native message shapes ───────────────────────────────────────────────────── +// +// Structural subsets of each provider's message-param types, declared here so +// canonical.ts stays dependency-free. Providers pass the converter output to +// their SDKs, where structural typing (or a single cast at the call site) +// takes over. + +/** Anthropic Messages API content blocks (the subset codeoid emits). */ +export type AnthropicContentBlock = + | { type: "text"; text: string } + | { type: "tool_use"; id: string; name: string; input: Record } + | { type: "tool_result"; tool_use_id: string; content: string; is_error?: boolean }; + +export interface AnthropicMessageParam { + role: "user" | "assistant"; + content: string | AnthropicContentBlock[]; +} -/** - * Render a CanonicalToolCall as inline text for providers that don't natively - * support function calling (Phase 1 fallback). - */ -function toolCallToText(tc: CanonicalToolCall): string { - const inputStr = Object.entries(tc.input) - .map(([k, v]) => `${k}=${JSON.stringify(v)}`) - .join(", "); - const status = tc.success ? "" : " [ERROR]"; - const outputPreview = tc.output.length > 500 - ? `${tc.output.slice(0, 500)}…` - : tc.output; - return `[Tool: ${tc.name}(${inputStr})${status}]\n${outputPreview}`; +export type GeminiPart = + | { text: string } + | { functionCall: { name: string; args: Record } } + | { functionResponse: { name: string; response: Record } }; + +/** @google/generative-ai Content — functionResponse parts ride role "function". */ +export interface GeminiContent { + role: "user" | "model" | "function"; + parts: GeminiPart[]; } +export type OpenAIMessageParam = + | { role: "system" | "user"; content: string } + | { + role: "assistant"; + content: string | null; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }>; + } + | { role: "tool"; tool_call_id: string; content: string }; + +// ── History converters ──────────────────────────────────────────────────────── + /** * Render a CanonicalTurn[] as Google Gemini Content[]. * - * Phase 1: tool calls are inlined as text in the model turn. - * Phase 2: replace the inline-text block below with proper - * { functionCall: { name, args } } parts in the model turn and a - * follow-up user turn with { functionResponse: { name, response } } parts. + * Tool calls become native { functionCall } parts on the model turn, + * followed by a role:"function" turn carrying { functionResponse } parts + * (the shape @google/generative-ai validates for chat history). Gemini + * pairs responses by NAME, not id — a limitation of its wire format. */ -export function toGeminiContent( - history: readonly CanonicalTurn[], -): Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> { - const out: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> = []; +export function toGeminiContent(history: readonly CanonicalTurn[]): GeminiContent[] { + const out: GeminiContent[] = []; for (const turn of history) { if (turn.role === "user") { @@ -135,19 +172,27 @@ export function toGeminiContent( continue; } - // Assistant turn — build text with optional tool-call summary. - const parts: string[] = []; - if (turn.content) parts.push(turn.content); - - // Phase 2: replace this block with native functionCall/functionResponse parts. - if (turn.toolCalls && turn.toolCalls.length > 0) { - parts.push("\n\n[Tool calls executed by previous agent:]"); - for (const tc of turn.toolCalls) { - parts.push(toolCallToText(tc)); - } + if (!turn.toolCalls || turn.toolCalls.length === 0) { + out.push({ role: "model", parts: [{ text: turn.content }] }); + continue; } - out.push({ role: "model", parts: [{ text: parts.join("\n") }] }); + const parts: GeminiPart[] = []; + if (turn.content) parts.push({ text: turn.content }); + for (const tc of turn.toolCalls) { + parts.push({ functionCall: { name: tc.name, args: tc.input } }); + } + out.push({ role: "model", parts }); + out.push({ + role: "function", + parts: turn.toolCalls.map((tc) => ({ + functionResponse: { + name: tc.name, + // functionResponse.response must be an OBJECT — wrap the text. + response: { output: tc.output, success: tc.success }, + }, + })), + }); } return out; @@ -156,14 +201,12 @@ export function toGeminiContent( /** * Render a CanonicalTurn[] as OpenAI ChatCompletionMessageParam[]. * - * Phase 1: tool calls are inlined as text in the assistant turn. - * Phase 2: replace the inline-text block below with proper - * assistant.tool_calls[] + { role: "tool" } messages. + * Tool calls become native assistant `tool_calls[]` followed by one + * role:"tool" message per call (paired by `tool_call_id`). OpenAI accepts + * tool-history replay without the original function schemas declared. */ -export function toOpenAIMessages( - history: readonly CanonicalTurn[], -): Array<{ role: "user" | "assistant" | "system"; content: string }> { - const out: Array<{ role: "user" | "assistant" | "system"; content: string }> = []; +export function toOpenAIMessages(history: readonly CanonicalTurn[]): OpenAIMessageParam[] { + const out: OpenAIMessageParam[] = []; for (const turn of history) { if (turn.role === "user") { @@ -171,18 +214,30 @@ export function toOpenAIMessages( continue; } - const parts: string[] = []; - if (turn.content) parts.push(turn.content); - - // Phase 2: replace this block with tool_calls[] + tool role messages. - if (turn.toolCalls && turn.toolCalls.length > 0) { - parts.push("\n\n[Tool calls executed by previous agent:]"); - for (const tc of turn.toolCalls) { - parts.push(toolCallToText(tc)); - } + if (!turn.toolCalls || turn.toolCalls.length === 0) { + out.push({ role: "assistant", content: turn.content }); + continue; } - out.push({ role: "assistant", content: parts.join("\n") }); + out.push({ + role: "assistant", + // OpenAI wants null (not "") when the turn is tool-calls-only. + content: turn.content || null, + tool_calls: turn.toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { name: tc.name, arguments: JSON.stringify(tc.input) }, + })), + }); + for (const tc of turn.toolCalls) { + // OpenAI has no is_error equivalent — failures are carried in the + // content string (design doc §11.3). + out.push({ + role: "tool", + tool_call_id: tc.id, + content: tc.success ? tc.output : `Error: ${tc.output}`, + }); + } } return out; @@ -190,16 +245,21 @@ export function toOpenAIMessages( /** * Render a CanonicalTurn[] as Anthropic API messages[]. - * Used when ClaudeProvider is switched into stateless mode (Phase 2), + * Used when ClaudeProvider is switched into stateless mode, * or when seeding a new Claude backing session with prior context. * - * Phase 1: tool calls are inlined as text. - * Phase 2: replace with proper tool_use/tool_result content blocks. + * Tool calls become native `tool_use` blocks on the assistant message, + * followed by a user message of `tool_result` blocks paired by + * `tool_use_id` (the CanonicalToolCall id round-trips). Consecutive + * same-role messages are legal — the API merges them into one turn. + * + * `thinking` is deliberately NOT emitted: replayed thinking blocks must + * carry the API's signature, which synthesized history can't produce. */ export function toAnthropicMessages( history: readonly CanonicalTurn[], -): Array<{ role: "user" | "assistant"; content: string }> { - const out: Array<{ role: "user" | "assistant"; content: string }> = []; +): AnthropicMessageParam[] { + const out: AnthropicMessageParam[] = []; for (const turn of history) { if (turn.role === "user") { @@ -207,18 +267,27 @@ export function toAnthropicMessages( continue; } - const parts: string[] = []; - if (turn.content) parts.push(turn.content); - - // Phase 2: replace with tool_use + tool_result content blocks. - if (turn.toolCalls && turn.toolCalls.length > 0) { - parts.push("\n\n[Tool calls executed by previous agent:]"); - for (const tc of turn.toolCalls) { - parts.push(toolCallToText(tc)); - } + if (!turn.toolCalls || turn.toolCalls.length === 0) { + out.push({ role: "assistant", content: turn.content }); + continue; } - out.push({ role: "assistant", content: parts.join("\n") }); + const blocks: AnthropicContentBlock[] = []; + // Empty text blocks are rejected by the API — only emit when non-empty. + if (turn.content) blocks.push({ type: "text", text: turn.content }); + for (const tc of turn.toolCalls) { + blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.input }); + } + out.push({ role: "assistant", content: blocks }); + out.push({ + role: "user", + content: turn.toolCalls.map((tc) => ({ + type: "tool_result" as const, + tool_use_id: tc.id, + content: tc.output, + ...(tc.success ? {} : { is_error: true }), + })), + }); } return out; @@ -338,17 +407,45 @@ export class CanonicalHistoryAccumulator { /** Character budget for a rendered history seed (~6k tokens). */ export const HISTORY_SEED_MAX_CHARS = 24_000; +/** Per-tool output budget inside the seed — the global budget drops whole + * turns oldest-first, this keeps one chatty tool from eating a turn. */ +const SEED_TOOL_OUTPUT_MAX_CHARS = 2_000; + +/** + * Render one tool call as a structured text block for the seed: full input + * as JSON, fenced output. Structured-text — richer than a one-line summary, + * still prose (see renderHistorySeed's fidelity contract). + */ +function toolCallToSeedText(tc: CanonicalToolCall): string { + const output = + tc.output.length > SEED_TOOL_OUTPUT_MAX_CHARS + ? `${tc.output.slice(0, SEED_TOOL_OUTPUT_MAX_CHARS)}\n…output truncated for seed…` + : tc.output; + return [ + `### Tool call: ${tc.name} → ${tc.success ? "ok" : "ERROR"}`, + `input: ${JSON.stringify(tc.input)}`, + "output:", + "```", + output, + "```", + ].join("\n"); +} + /** - * Render the canonical history as a plain-text transcript block for seeding - * a NEW warm backend after `session.set_provider`. Stateless providers - * ignore this (they consume `TurnOpts.history` natively every turn); warm - * providers (claude, pi) prepend it to their first post-switch prompt so - * the incoming agent can continue the conversation. + * Render the canonical history as a structured-text transcript block for + * seeding a NEW warm backend after `session.set_provider`. Stateless + * providers ignore this (they consume `TurnOpts.history` natively every + * turn — see the to*Messages converters above); warm providers (claude, pi) + * prepend it to their first post-switch prompt so the incoming agent can + * continue the conversation. * * Fidelity contract: this is a faithful TRANSCRIPT, not a native - * continuation — tool calls are flattened to text and provider-native - * structures don't survive. Oldest turns are dropped first when the budget - * is exceeded (recent context matters most), with an elision note. + * continuation. Tool calls are rendered as structured text blocks (name, + * JSON input, fenced output) rather than native tool_use structures — + * neither the Claude Agent SDK nor pi's RPC accepts synthesized native + * history injection, so a prompt-prefix transcript is the warm-backend + * ceiling. Oldest turns are dropped first when the budget is exceeded + * (recent context matters most), with an elision note. */ export function renderHistorySeed( history: readonly CanonicalTurn[], @@ -364,7 +461,7 @@ export function renderHistorySeed( const parts: string[] = [`## Assistant (${turn.providerId}/${turn.model})`]; if (turn.content) parts.push(turn.content); for (const tc of turn.toolCalls ?? []) { - parts.push(toolCallToText(tc)); + parts.push(toolCallToSeedText(tc)); } return parts.join("\n"); }); @@ -386,9 +483,10 @@ export function renderHistorySeed( return [ "", "You are taking over an ongoing session from another agent backend.", - "The conversation so far (tool calls flattened to text, possibly", - "truncated) follows. Continue it seamlessly — do not re-introduce", - "yourself or repeat completed work.", + "The conversation so far follows — tool calls appear as structured", + "blocks (name, input JSON, fenced output), possibly truncated.", + "Continue it seamlessly — do not re-introduce yourself or repeat", + "completed work.", "", kept.join("\n\n"), "", diff --git a/src/daemon/providers/gemini/index.ts b/src/daemon/providers/gemini/index.ts index 7054169..a892fd4 100644 --- a/src/daemon/providers/gemini/index.ts +++ b/src/daemon/providers/gemini/index.ts @@ -6,9 +6,10 @@ * No persistent session state: the entire conversation context is resent on * every turn, which is the Gemini API's natural model. * - * Phase 1: text-only. Tool calls from prior Claude turns are rendered as - * inline text so Gemini has full context. Function calling support (Phase 2) - * will replace this with proper functionCall/functionResponse parts. + * History fidelity: tool calls from prior turns (any backend) arrive as + * native functionCall/functionResponse parts via toGeminiContent(), so + * Gemini sees real tool-call turns. Gemini itself remains text-only in + * its OWN turns (no function-calling loop here yet). * * Auth: reads GOOGLE_API_KEY from the environment. Override with * GeminiProviderInit.apiKey for programmatic control (tests, multi-tenant). diff --git a/src/daemon/providers/openai/index.ts b/src/daemon/providers/openai/index.ts index 21293ac..0a87765 100644 --- a/src/daemon/providers/openai/index.ts +++ b/src/daemon/providers/openai/index.ts @@ -4,9 +4,11 @@ * Each runTurn() call converts the full CanonicalTurn[] history to OpenAI's * messages[] format and issues a single streaming chat completion request. * - * Phase 1: text-only. Tool calls from prior Claude turns are rendered as - * inline text. Function calling support (Phase 2) will use tool_calls[] - * + { role: "tool" } messages with proper CanonicalToolCall rendering. + * History fidelity: tool calls from prior turns (any backend) arrive as + * native assistant tool_calls[] + { role: "tool" } messages via + * toOpenAIMessages(), so the model sees real tool-call turns. The + * provider itself remains text-only in its OWN turns (no function-calling + * loop here yet). * * Auth: reads OPENAI_API_KEY from the environment. Override with * OpenAIProviderInit.apiKey for programmatic control. diff --git a/src/tests/pi-translate.test.ts b/src/tests/pi-translate.test.ts index 3bec477..4efebd4 100644 --- a/src/tests/pi-translate.test.ts +++ b/src/tests/pi-translate.test.ts @@ -144,11 +144,38 @@ describe("renderHistorySeed", () => { expect(seed).toContain(""); expect(seed).toContain("## User\nfix the bug"); expect(seed).toContain("## Assistant (claude/opus)"); - expect(seed).toContain("run_shell"); + // Structured tool block — not the old one-line "[Tool: …]" flattening. + expect(seed).toContain("### Tool call: run_shell → ok"); + expect(seed).toContain(`input: {"command":"bun test"}`); expect(seed).toContain("1 pass"); + expect(seed).not.toContain("[Tool:"); expect(seed).toContain(""); }); + it("marks failed tool calls and truncates oversized outputs per-tool", () => { + const seed = renderHistorySeed([ + { + role: "assistant", + content: "Ran it.", + providerId: "pi", + model: "m", + toolCalls: [ + { + id: "t1", + name: "run_shell", + input: { command: "bad" }, + output: "x".repeat(5_000), + success: false, + }, + ], + }, + ]); + expect(seed).toContain("### Tool call: run_shell → ERROR"); + expect(seed).toContain("…output truncated for seed…"); + // Global budget note should NOT appear for a single small turn. + expect(seed).not.toContain("omitted for length"); + }); + it("returns empty for empty history", () => { expect(renderHistorySeed([])).toBe(""); }); diff --git a/src/tests/provider-pi.test.ts b/src/tests/provider-pi.test.ts index 0389061..230b51b 100644 --- a/src/tests/provider-pi.test.ts +++ b/src/tests/provider-pi.test.ts @@ -254,6 +254,15 @@ describe("PiProvider", () => { content: "earlier answer", providerId: "claude", model: "opus", + toolCalls: [ + { + id: "toolu_01", + name: "run_shell", + input: { command: "bun test" }, + output: "1 pass", + success: true, + }, + ], }, ]); const events = await collect(p.runTurn(turnOpts("echo-prompt")).events); @@ -264,6 +273,11 @@ describe("PiProvider", () => { expect(done.content).toContain(""); expect(done.content).toContain("earlier question"); expect(done.content).toContain("earlier answer"); + // Claude→pi round-trip carries STRUCTURED tool history, not the old + // one-line "[Tool: …]" flattening. + expect(done.content).toContain("### Tool call: run_shell → ok"); + expect(done.content).toContain(`input: {"command":"bun test"}`); + expect(done.content).not.toContain("[Tool:"); expect(done.content).toContain("echo-prompt"); } diff --git a/src/tests/provider-switch.test.ts b/src/tests/provider-switch.test.ts index 5c9ef49..a305c04 100644 --- a/src/tests/provider-switch.test.ts +++ b/src/tests/provider-switch.test.ts @@ -299,7 +299,7 @@ describe("toGeminiContent", () => { expect(result[2]).toEqual({ role: "user", parts: [{ text: "How are you?" }] }); }); - it("inlines tool calls as text in the model turn (Phase 1)", () => { + it("renders tool calls as native functionCall/functionResponse parts (Phase 2)", () => { const history: import("../daemon/providers/canonical.js").CanonicalTurn[] = [ { role: "user", content: "Run ls" }, { @@ -317,10 +317,43 @@ describe("toGeminiContent", () => { }, ]; const result = toGeminiContent(history); - expect(result[1].role).toBe("model"); - // Tool call should appear as inline text - expect(result[1].parts[0].text).toContain("run_shell"); - expect(result[1].parts[0].text).toContain("main.ts"); + expect(result).toHaveLength(3); // user, model, function + expect(result[1]).toEqual({ + role: "model", + parts: [ + { text: "Here are the files:" }, + { functionCall: { name: "run_shell", args: { command: "ls" } } }, + ], + }); + expect(result[2]).toEqual({ + role: "function", + parts: [ + { + functionResponse: { + name: "run_shell", + response: { output: "main.ts\nindex.ts", success: true }, + }, + }, + ], + }); + }); + + it("omits the text part on a tool-calls-only model turn", () => { + const history: import("../daemon/providers/canonical.js").CanonicalTurn[] = [ + { + role: "assistant", + content: "", + providerId: "claude", + model: "m", + toolCalls: [ + { id: "t1", name: "read_file", input: { file_path: "a.ts" }, output: "x", success: true }, + ], + }, + ]; + const result = toGeminiContent(history); + expect(result[0]?.parts).toEqual([ + { functionCall: { name: "read_file", args: { file_path: "a.ts" } } }, + ]); }); it("returns empty array for empty history", () => { @@ -340,7 +373,7 @@ describe("toOpenAIMessages", () => { expect(result[1]).toEqual({ role: "assistant", content: "Hi!" }); }); - it("inlines tool calls as text in the assistant message (Phase 1)", () => { + it("renders tool calls as native tool_calls[] + tool-role messages (Phase 2)", () => { const history: import("../daemon/providers/canonical.js").CanonicalTurn[] = [ { role: "assistant", @@ -348,7 +381,7 @@ describe("toOpenAIMessages", () => { providerId: "claude", model: "claude-opus-4-5", toolCalls: [{ - id: "t1", + id: "call_1", name: "read_file", input: { file_path: "src/main.ts" }, output: "export default {}", @@ -357,8 +390,55 @@ describe("toOpenAIMessages", () => { }, ]; const result = toOpenAIMessages(history); - expect(result[0].content).toContain("read_file"); - expect(result[0].content).toContain("export default {}"); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + role: "assistant", + content: "Done.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "read_file", arguments: JSON.stringify({ file_path: "src/main.ts" }) }, + }, + ], + }); + expect(result[1]).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: "export default {}", + }); + }); + + it("uses null content on a tool-calls-only assistant message", () => { + const history: import("../daemon/providers/canonical.js").CanonicalTurn[] = [ + { + role: "assistant", + content: "", + providerId: "claude", + model: "m", + toolCalls: [ + { id: "c1", name: "run_shell", input: { command: "ls" }, output: "ok", success: true }, + ], + }, + ]; + const result = toOpenAIMessages(history); + expect(result[0]).toMatchObject({ role: "assistant", content: null }); + }); + + it("carries failures in the content string — OpenAI has no is_error (§11.3)", () => { + const history: import("../daemon/providers/canonical.js").CanonicalTurn[] = [ + { + role: "assistant", + content: "", + providerId: "claude", + model: "m", + toolCalls: [ + { id: "c1", name: "run_shell", input: { command: "bad" }, output: "exit 127", success: false }, + ], + }, + ]; + const result = toOpenAIMessages(history); + expect(result[1]).toEqual({ role: "tool", tool_call_id: "c1", content: "Error: exit 127" }); }); }); @@ -373,6 +453,78 @@ describe("toAnthropicMessages", () => { expect(result[0]).toEqual({ role: "user", content: "Hello" }); expect(result[1]).toEqual({ role: "assistant", content: "Hi!" }); }); + + it("renders tool calls as tool_use blocks + a tool_result user message (Phase 2)", () => { + const history: import("../daemon/providers/canonical.js").CanonicalTurn[] = [ + { role: "user", content: "Read main.ts and check the config" }, + { + role: "assistant", + content: "Reading both.", + thinking: "the user wants two files", + providerId: "claude", + model: "claude-opus-4-5", + toolCalls: [ + { + id: "toolu_01", + name: "read_file", + input: { file_path: "src/main.ts" }, + output: "export {}", + success: true, + }, + { + id: "toolu_02", + name: "read_file", + input: { file_path: "config.json" }, + output: "ENOENT", + success: false, + }, + ], + }, + { role: "user", content: "Thanks" }, + ]; + const result = toAnthropicMessages(history); + expect(result).toHaveLength(4); // user, assistant(blocks), user(tool_results), user + expect(result[1]).toEqual({ + role: "assistant", + content: [ + { type: "text", text: "Reading both." }, + { type: "tool_use", id: "toolu_01", name: "read_file", input: { file_path: "src/main.ts" } }, + { type: "tool_use", id: "toolu_02", name: "read_file", input: { file_path: "config.json" } }, + ], + }); + // tool_use ids round-trip into the paired tool_result blocks; failures + // carry is_error. Synthesized thinking is deliberately absent (no + // signature — the API would reject it). + expect(result[2]).toEqual({ + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_01", content: "export {}" }, + { type: "tool_result", tool_use_id: "toolu_02", content: "ENOENT", is_error: true }, + ], + }); + expect(JSON.stringify(result)).not.toContain("thinking"); + }); + + it("omits the empty text block on a tool-calls-only turn", () => { + const history: import("../daemon/providers/canonical.js").CanonicalTurn[] = [ + { + role: "assistant", + content: "", + providerId: "claude", + model: "m", + toolCalls: [ + { id: "t1", name: "run_shell", input: { command: "ls" }, output: "ok", success: true }, + ], + }, + ]; + const result = toAnthropicMessages(history); + const content = result[0]?.content; + expect(Array.isArray(content)).toBe(true); + if (Array.isArray(content)) { + expect(content).toHaveLength(1); + expect(content[0]?.type).toBe("tool_use"); + } + }); }); describe("splitForStateless", () => {