feat: Phase-2 canonical history — native tool_use/functionCall replay across backends - #138
Conversation
… across backends The canonical accumulator has always captured tool calls structurally (CanonicalToolCall with id/input/output/success); only the rendering side was lossy — every to<Provider>Messages converter flattened tool calls to "[Tool: …]" prose. This rewrites the converters to emit each provider's NATIVE structure, so a switched session's history reads as real tool-call turns instead of a narrated summary: - toAnthropicMessages: assistant tool_use blocks + a paired tool_result user message (tool_use_id round-trips from CanonicalToolCall.id, failures carry is_error). Consecutive same-role messages are legal — the API merges them. - toGeminiContent: functionCall parts on the model turn + a role:"function" turn of functionResponse parts — the shape @google/generative-ai's validateChatHistory actually enforces (the design doc sketched role:"user"; corrected). Gemini pairs by name (no id in its wire format). - toOpenAIMessages: assistant tool_calls[] + role:"tool" messages paired by tool_call_id; failures serialize as "Error: <output>" per design doc §11.3 (OpenAI has no is_error); tool-calls-only turns carry content: null. - renderHistorySeed (warm backends, claude/pi): upgraded from one-line flattening to structured blocks (name, input JSON, fenced output, 2k per-tool cap inside the existing 24k oldest-dropped budget). The string seed remains the warm-backend ceiling — neither the Claude Agent SDK nor pi RPC accepts synthesized native-history injection; documented in docs/providers-pi.md + the session.set_provider protocol comment. - thinking is captured but deliberately NOT replayed anywhere: Anthropic requires signed thinking blocks (synthesized ones are rejected) and neither Gemini nor OpenAI accepts imported reasoning. Stateless backends benefit immediately (they convert history every turn); the switch loop itself is unchanged. Behavior-neutral for sessions that never used tools. Tests: native-structure converter units (ids round-trip, is_error, Error: prefix, tool-calls-only edge cases), structured-seed assertions, and a claude→pi round-trip over fake-pi asserting the seed carries structured tool history, not the flattened form.
🤖 Gemini code reviewThis PR implements Phase 2 of the canonical history plan, rendering history in each provider's native structure (Anthropic tool_use/tool_result, Gemini functionCall/functionResponse, OpenAI tool_calls/tool-role messages) instead of flattened prose. However, there is a critical compatibility issue with the Anthropic converter where consecutive same-role messages are emitted, which will be rejected by the Anthropic Messages API. Findings: 🔴 0 · 🟠 1 · 🟡 1 · 🟢 0 Tokens spent · ⬆️ Input: 10,152 · ⬇️ Output: 965 · Σ Total: 23,167 |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #138 +/- ##
==========================================
+ Coverage 81.57% 81.61% +0.03%
==========================================
Files 95 95
Lines 16406 16439 +33
==========================================
+ Hits 13384 13417 +33
Misses 3022 3022
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
* fix: close post-merge test gaps in hooks + Phase-2 history Post-merge audit of #137/#138 found four untested behaviors; all now covered, no product code changes: - canonical-sdk-compat.test.ts: toGeminiContent output validated by the REAL @google/generative-ai validateChatHistory (startChat constructor, offline) with a negative control proving the validator fires — pins the role:"function" decision to the SDK, not to a code comment. toOpenAIMessages output assigned to ChatCompletionMessageParam[] WITHOUT a cast so typecheck enforces shape compatibility (the provider call site casts, which would hide drift). - session-hooks H8: hook input mutation composed with MANUAL approval — the approval UI delta shows the hook-rewritten input and the approved merge base is the mutated input, not the model's original. - session-hooks H9/H10/H11: provider_switched, rotated, and session_end observe emits (previously only session_start/after_turn were covered). Also hardens the observe-hook file waits: wait for parseable JSON, not file existence — 'cat > file' creates the file before the payload lands. * fix: run the real-SDK Gemini validation in a subprocess fixture provider-gemini.test.ts installs a process-global mock.module("@google/generative-ai"), so whether an in-process import sees the real SDK depends on test-file execution order — passed locally, failed in CI (startChat returned the mock's {sendMessageStream} stub and the negative control stopped throwing). Move the validation into a spawned fixture (fake-pi pattern): a fresh process is mock-proof and order-independent.
What
Phase 2 of the canonical-history plan (
docs/multi-provider-meta-harness.md). Capture was already structured —CanonicalTurncarriescontent,thinking, andCanonicalToolCall[]with ids/inputs/outputs — but everyto<Provider>Messagesconverter flattened tool calls into[Tool: …]prose, so a switched session got a narrated summary instead of a native continuation. This PR rewrites the rendering side only; no new capture, no switch-loop changes.Converters
toAnthropicMessagestool_useblocks + a paired user message oftool_resultblocks.tool_use_idround-trips fromCanonicalToolCall.id; failures carryis_error: true. Empty text blocks are omitted (API rejects them); consecutive same-role messages are fine (API merges).toGeminiContentfunctionCallparts on the model turn + a role"function"turn offunctionResponseparts. Verified against@google/generative-ai'svalidateChatHistory(VALID_PARTS_PER_ROLE) — the design doc sketched role"user"for responses, which the SDK rejects; doc corrected. Gemini pairs by name (its wire format has no call id).functionResponse.responsemust be an object, so output rides{ output, success }.toOpenAIMessagestool_calls[]+role:"tool"messages paired bytool_call_id. Failures serialize asError: <output>per design doc §11.3 (OpenAI has nois_error); tool-calls-only turns carrycontent: null.Stateless backends (gemini, openai) benefit immediately — they run the converter every turn, so prior tool activity (from any backend) now arrives as real function-call turns.
Warm-backend seed
renderHistorySeed(claude, piseedFromHistory) upgrades from the one-line flattening to structured blocks — tool name + status, full input JSON, fenced output (2k per-tool cap inside the existing 24k oldest-dropped budget). Investigated the higher-fidelity option: neither the Claude Agent SDK nor pi RPC accepts synthesized native-history injection (the SDK resumes only from its own persisted session files, an internal format), so the string seed remains the warm-backend ceiling. That contract is now stated indocs/providers-pi.mdand thesession.set_providerprotocol comment.Honest limits (documented in canonical.ts)
thinkingis captured but not replayed into any provider payload: Anthropic requires a cryptographic signature on replayed thinking blocks (synthesized ones are rejected — and the API ignores prior-turn thinking anyway); Gemini/OpenAI don't accept imported reasoning. Display-only by design.CanonicalTurnflattens an agent loop (text → tool → text …), so converters emit the parallel-tool-call shape — valid everywhere, but intra-turn interleaving isn't reconstructed.Tests
tool_use/tool_resultid round-trip +is_error, GeminifunctionCall/functionResponseon rolefunction, OpenAItool_calls+ tool-role pairing +Error:prefix, tool-calls-only edge cases (no empty text block /content: null), thinking absent from Anthropic output.### Tool call: run_shell → ok, input JSON, fenced output), failure marking, per-tool truncation, old[Tool:form asserted gone.bun run test(1198 pass),typecheck,lintall green. Wire-neutral — protocol change is comment-only.🤖 Generated with Claude Code