Skip to content

fix(anthropic): lay server-tool blocks out per hop in non-streaming turns - #152

Merged
orangeboyChen merged 2 commits into
mainfrom
worktree-fix-nonstream-server-tool-block-order
Sep 17, 2026
Merged

orangeboyChen merged 2 commits into
mainfrom
worktree-fix-nonstream-server-tool-block-order

Conversation

@orangeboyChen

@orangeboyChen orangeboyChen commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Problem

A non-streaming turn that ran a server tool came back with every tool block hoisted ahead of all the prose, and each hop's reasoning and text merged into one block apiece:

[server_tool_use] [tool_result] [thinking1+thinking2] [text1+text2]

Anthropic lays an assistant turn out per hop instead — each hop contributes its own thinking and text, followed by the tool blocks that hop triggered, repeated:

[thinking] [text] [tool_use] [tool_result] [thinking] [text]

The old shape put each search ahead of the reasoning that asked for it, and merged hops that were never contiguous.

Root cause

The merging is not fixable at the renderer: withIntermediateTurns joins every hop into a single content and a single reasoning_content, because that is all the OpenAI-shaped payload can carry — and the join happens before any content block exists.

Fix

  1. The loop now also emits turns — one entry per hop, keeping that hop's reasoning, text, and calls grouped and in order. The joined strings stay as the OpenAI-shaped view, so chat-completions clients are unaffected. Adds a ServerToolTurn type.
  2. The Anthropic map builds blocks per hop via buildAnthropicTurnBlocks when turns is present.
  3. Paths without turns keep the previous behaviour — thinking and text followed by their tool blocks, which is the correct order when no server tool ran and there is only one hop.
  4. A tool-only hop (the model calls a tool without speaking first) still becomes its own hop, so its tool blocks correctly open the turn.

Tests

  • New: a two-hop fetch case asserting the exact block sequence.
  • New: an edge case for the tool-only hop, pinning that the tool blocks come first. This scenario was already covered by an existing test and the change nearly broke it, so it is now asserted explicitly.
  • All 667 tests pass; typecheck, eslint, and prettier are clean.

Scope

Non-streaming only. The streaming path already emits the correct layout and is untouched.

Separately: this investigation also confirmed that the thinking1||thinking2 / content1||content2 rendering seen in the lobehub UI is not caused by this project. It originates in lobehub's own stream accumulation (two += scalar accumulators discard ordering before anything is stored or rendered), not in the API responses this proxy emits.

…urns

A non-streaming turn that ran a server tool came back with every tool
block hoisted ahead of all the prose, and each hop's reasoning and text
merged into one block apiece:

    [server_tool_use] [tool_result] [thinking1+thinking2] [text1+text2]

Anthropic lays an assistant turn out per hop instead — thinking and text,
then the tools they led to, repeated — so a client replaying the message
expects [thinking] [text] [tool_use] [tool_result] [thinking] [text].
The old shape put each search ahead of the reasoning that asked for it
and merged hops that were never contiguous.

The merged strings are not fixable at the renderer: `withIntermediateTurns`
joins every hop into one `content` and one `reasoning_content`, because
that is all the OpenAI-shaped payload can carry, and the join happens
before any block exists. So the loop now also emits `turns`, keeping each
hop's reasoning, text, and calls grouped and in order, and the Anthropic
map builds blocks from that when present.

Paths that never group the hops — no server tool ran — keep the previous
behaviour of thinking and text followed by their tool blocks.

@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: c105e11bb6

ℹ️ 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".

// content blocks needs to know where one hop's reasoning ends and the next
// begins, which a joined string has already lost. The closing hop is the
// model's final answer, so it carries no further calls.
turns: buildIntermediateTurns({

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 Keep Anthropic hop metadata out of chat responses

When a non-streaming /v1/chat/completions request executes a local web tool, this property is serialized by Response.json, and app/v1/chat/completions/route.ts returns proxyChatCompletions unchanged. The OpenAI-compatible endpoint therefore exposes a non-protocol turns field containing internal inputs and results; strict response validators can reject it, and every client receives duplicated tool data. Carry the grouping out of band, as already done for serverToolExecutions, or add it only inside the Anthropic adapter.

Useful? React with 👍 / 👎.

Comment on lines 1673 to +1675
message: withIntermediateTurns({
payload,
executions: intermediateExecutions,

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 hop metadata in mixed-tool responses

When a later hop mixes a local server tool with a client-owned tool, withIntermediateTurns(...) creates turns only on its returned payload, but this branch extracts just .message and then gives buildMixedTurnPayload the original payload. The response consequently has no hop metadata and never groups the current executions, so the Anthropic mapper flattens all reasoning and text before all server-tool blocks—for example, after a search-only hop followed by search plus client_tool. Preserve the prior turns and append the current execution group when constructing the mixed response.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.64286% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.41%. Comparing base (80f4125) to head (eb67437).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #152      +/-   ##
==========================================
- Coverage   95.43%   95.41%   -0.02%     
==========================================
  Files          36       36              
  Lines        6329     6368      +39     
  Branches     1809     1817       +8     
==========================================
+ Hits         6040     6076      +36     
- Misses        289      292       +3     
Flag Coverage Δ
unittests 95.41% <94.64%> (-0.02%) ⬇️

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.

The changed-branch coverage check gates at 90% and the previous commit
landed at 88.24%.

Two of the gaps were fallbacks I had added that cannot be reached:
`reasonings[index] ?? ''` and `texts[index] ?? ''` in
`buildIntermediateTurns`. The loop appends to all three hop arrays on
every hop and the closing prose is appended to `texts` and `reasonings`
together, so those two are always the same length and the fallback never
runs. Indexing them directly makes that invariant load-bearing instead
of papered over. `executions` genuinely does run one short — the closing
hop answers without calling anything — so its fallback stays.

The other gap was real: `execution.result.url ?? execution.input.url`.
A fetch backend reports the URL it actually read, which follows redirects
and so can differ from the one the model asked for. The existing test
mocked a backend that omits `url`, covering only the fallback, so the
mock now returns one and the test asserts the result carries it.
@orangeboyChen
orangeboyChen merged commit 8edfe8a into main Sep 17, 2026
7 checks passed
@orangeboyChen
orangeboyChen deleted the worktree-fix-nonstream-server-tool-block-order branch September 17, 2026 09:01
orangeboyChen added a commit that referenced this pull request Sep 17, 2026
#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`.
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