Skip to content

fix(anthropic): carry server-tool hop metadata beside the response - #157

Merged
orangeboyChen merged 1 commit into
mainfrom
worktree-fix-server-tool-turns-out-of-band
Sep 17, 2026
Merged

orangeboyChen merged 1 commit into
mainfrom
worktree-fix-server-tool-turns-out-of-band

Conversation

@orangeboyChen

Copy link
Copy Markdown
Owner

Follows up on #152, addressing both Codex review comments from that PR.

#152 laid non-streaming server-tool blocks out per hop, but put the per-hop grouping it needed on the chat-completion payload itself. That payload is what an OpenAI-protocol client receives, so two things went wrong.

1. Hop metadata leaked into chat-completions responses

withIntermediateTurns attached turns to the payload, and Response.json serialized it. app/v1/chat/completions/route.ts returns proxyChatCompletions unchanged, so /v1/chat/completions exposed a non-protocol turns field containing internal tool inputs and results — strict validators can reject it, and every client received the tool data twice.

Fixed by carrying the grouping out of band through a WeakMap, the way serverToolExecutions already is: attachServerToolTurns / getServerToolTurns. The Anthropic adapter reads it off the response instead of the payload.

2. Mixed server/client-tool turns lost the grouping

When a later hop mixed a local server tool with a client-owned tool, withIntermediateTurns built the turns but the mixed branch took only .message off its result, and never grouped the current hop's calls. The response had no hop metadata, so buildAnthropicTurnBlocks was never used and the mapper flattened all reasoning and text ahead of all server-tool blocks — the exact ordering bug #152 set out to fix. Reproduced by a search-only hop followed by a hop mixing search with client_tool.

Fixed by preserving the prior turns and appending the current hop's execution group when constructing the mixed response.

Verification

  • Two regression tests, one per comment. Each was confirmed to fail with its fix reverted and pass with it applied, so both guard the behaviour rather than the implementation.
  • withIntermediateTurns and buildMixedTurnPayload now return { payload, turns }, and ServerToolLoopResult.turns is required rather than optional, so every return path states its grouping explicitly instead of relying on a ?? [] fallback.
  • 724 tests pass; typecheck, eslint, prettier, and build are clean. Coverage 94.59% statements. Patch branch coverage 100% (6/6).

Non-streaming remains the affected path; the streaming path already emits the correct layout and is untouched.

@orangeboyChen
orangeboyChen enabled auto-merge (squash) September 17, 2026 10:09

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7fc9e8882a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/server/proxy/web-search-loop.ts Outdated
Comment on lines +1891 to +1893
executions: results.map((result) => result.execution),
reasoning: '',
text: '',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve prose in an initial mixed-tool hop

When the first upstream response contains both a locally executed server tool and a client-owned tool, priorTurns is empty because withIntermediateTurns returns no turns when there are no earlier hops. This newly appended turn then carries only the execution, even if the current message contains text or reasoning. Because the nonempty metadata makes mapOpenAIResponseToAnthropic render exclusively from turns, that current prose is silently omitted from the Anthropic response. Populate this turn with the current iteration's text and reasoning rather than empty strings.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.48%. Comparing base (3b6aa5d) to head (f1a4105).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #157      +/-   ##
==========================================
+ Coverage   95.47%   95.48%   +0.01%     
==========================================
  Files          37       37              
  Lines        6633     6650      +17     
  Branches     1910     1912       +2     
==========================================
+ Hits         6333     6350      +17     
  Misses        300      300              
Flag Coverage Δ
unittests 95.48% <100.00%> (+0.01%) ⬆️

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

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

#152 laid non-streaming server-tool blocks out per hop, but put the
grouping it needed on the chat-completion payload itself. That payload is
what an OpenAI-protocol client receives, so three things went wrong.

## 1. Hop metadata leaked into chat-completions responses

`withIntermediateTurns` attached `turns` to the payload, and `Response.json`
serialized it. `app/v1/chat/completions/route.ts` returns
`proxyChatCompletions` unchanged, so `/v1/chat/completions` exposed a
non-protocol `turns` field holding internal tool inputs and results — strict
validators can reject it, and every client received the tool data twice.

Carry the grouping out of band through a WeakMap, the way
`serverToolExecutions` already is: `attachServerToolTurns` /
`getServerToolTurns`. The Anthropic adapter reads it off the response
instead of the payload.

## 2. Mixed server/client-tool turns lost the grouping

When a later hop mixed a local server tool with a client-owned tool,
`withIntermediateTurns` built the turns but the mixed branch took only
`.message` off its result and never grouped the current hop. With no hop
metadata the mapper flattened all reasoning and text ahead of all
server-tool blocks — the exact ordering bug #152 set out to fix.

Preserve the prior turns and add the current hop's calls to its entry.

## 3. A first hop that mixed tools dropped its own prose

The mixed branch built the current hop with empty text and reasoning. When
that hop was also the first, `withIntermediateTurns` had no earlier hops to
fold and so returned no turns, and a block renderer renders purely from
`turns` once it is non-empty — so everything the model said in that hop
disappeared from the Anthropic response.

`withIntermediateTurns` already builds this hop as its closing entry, so the
calls are added to that entry rather than appended as a new one, which
would repeat the prose. Only when there are no earlier hops is a fresh
entry built, seeded with this iteration's text and reasoning.

## Verification

- Three regression tests, one per issue. Each was confirmed to fail with
  its fix reverted and pass with it applied, so they guard the behaviour
  rather than the implementation.
- `withIntermediateTurns` and `buildMixedTurnPayload` now return
  `{ payload, turns }`, and `ServerToolLoopResult.turns` is required rather
  than optional, so every return path states its grouping explicitly
  instead of relying on a `?? []` fallback.
- 733 tests pass; typecheck, eslint, prettier, and build are clean.
  Coverage 94.67% statements.

Rebased onto #156, which exports `withIntermediateTurns` to the
image-generation loop; those call sites now take `.payload`.
@orangeboyChen
orangeboyChen force-pushed the worktree-fix-server-tool-turns-out-of-band branch from 7fc9e88 to f1a4105 Compare September 17, 2026 10:22
@orangeboyChen

Copy link
Copy Markdown
Owner Author

Addressed the third review comment (prose dropped in a first mixed-tool hop), and rebased onto #156 to clear the conflict — the PR is MERGEABLE again.

The bug confirmed and fixed. With the empty strings restored, a first hop carrying both a local tool and a client-owned tool produced [server_tool_use, web_fetch_tool_result, tool_use]: the thinking and text blocks vanished from the Anthropic response entirely, not merely reordered.

One correction to the suggested remedy, in case it matters elsewhere: populate a newly appended turn with the current iteration's text and reasoning and that prose duplicates on later hops. withIntermediateTurns already builds the current hop as its closing entry — it appends readReasoning(message) and existingText before returning — so on a second mixed hop the list already ends with this hop's prose, and appending another entry repeats it. I hit this immediately, with text:Now yours. appearing twice.

The fix therefore adds the calls to that closing entry rather than appending a new one, and only builds a fresh entry when there are no earlier hops, seeded with this iteration's text and reasoning.

Tests. Three, one per issue, each verified to fail with its fix reverted and pass with it applied:

  • keeps hop metadata off the OpenAI chat-completions response — issue 1
  • keeps earlier hops grouped when a later hop mixes in a client tool — issue 2, and the guard against the duplication above
  • keeps the prose of a first hop that mixes in a client tool — issue 3

Also in this update: rebased onto #156, which exports withIntermediateTurns to the image-generation loop; those two call sites now take .payload to match the new return shape.

733 tests pass; typecheck, eslint, prettier, and build are clean. Coverage 94.67% statements, patch branch coverage 100% (8/8).

@orangeboyChen
orangeboyChen merged commit ce18a85 into main Sep 17, 2026
7 checks passed
@orangeboyChen
orangeboyChen deleted the worktree-fix-server-tool-turns-out-of-band branch September 17, 2026 10:26
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