Skip to content

feat: Phase-2 canonical history — native tool_use/functionCall replay across backends - #138

Merged
saucam merged 1 commit into
mainfrom
feat/native-history
Jul 10, 2026
Merged

feat: Phase-2 canonical history — native tool_use/functionCall replay across backends#138
saucam merged 1 commit into
mainfrom
feat/native-history

Conversation

@saucam

@saucam saucam commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What

Phase 2 of the canonical-history plan (docs/multi-provider-meta-harness.md). Capture was already structured — CanonicalTurn carries content, thinking, and CanonicalToolCall[] with ids/inputs/outputs — but every to<Provider>Messages converter 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

Converter Native shape emitted
toAnthropicMessages assistant tool_use blocks + a paired user message of tool_result blocks. tool_use_id round-trips from CanonicalToolCall.id; failures carry is_error: true. Empty text blocks are omitted (API rejects them); consecutive same-role messages are fine (API merges).
toGeminiContent functionCall parts on the model turn + a role "function" turn of functionResponse parts. Verified against @google/generative-ai's validateChatHistory (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.response must be an object, so output rides { output, success }.
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.

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, pi seedFromHistory) 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 in docs/providers-pi.md and the session.set_provider protocol comment.

Honest limits (documented in canonical.ts)

  • thinking is 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.
  • A CanonicalTurn flattens an agent loop (text → tool → text …), so converters emit the parallel-tool-call shape — valid everywhere, but intra-turn interleaving isn't reconstructed.

Tests

  • Converter units: tool_use/tool_result id round-trip + is_error, Gemini functionCall/functionResponse on role function, OpenAI tool_calls + tool-role pairing + Error: prefix, tool-calls-only edge cases (no empty text block / content: null), thinking absent from Anthropic output.
  • Seed: structured-block form (### Tool call: run_shell → ok, input JSON, fenced output), failure marking, per-tool truncation, old [Tool: form asserted gone.
  • Round-trip: claude→pi switch over fake-pi asserts the first post-switch prompt carries the structured tool history.

bun run test (1198 pass), typecheck, lint all green. Wire-neutral — protocol change is comment-only.

🤖 Generated with Claude Code

… 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.
Comment thread src/daemon/providers/canonical.ts
Comment thread src/tests/provider-switch.test.ts
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

🤖 Gemini code review

This 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
Total may be higher due to thinking token counts.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.61%. Comparing base (8cf7388) to head (140f133).
✅ All tests successful. No failed tests found.

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              
Flag Coverage Δ
daemon 81.61% <100.00%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
packages/protocol/src/types.ts 100.00% <ø> (ø)
src/daemon/providers/canonical.ts 100.00% <100.00%> (ø)
src/daemon/providers/gemini/index.ts 100.00% <ø> (ø)
src/daemon/providers/openai/index.ts 100.00% <ø> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@saucam
saucam merged commit 8e9e86a into main Jul 10, 2026
5 checks passed
saucam added a commit that referenced this pull request Jul 10, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant