Skip to content

fix(anthropic): move tool_result blocks first in mixed-content user messages - #8165

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
netbrah:fix/anthropic-tool-result-ordering
Jul 31, 2026
Merged

fix(anthropic): move tool_result blocks first in mixed-content user messages#8165
wenshao merged 1 commit into
QwenLM:mainfrom
netbrah:fix/anthropic-tool-result-ordering

Conversation

@netbrah

@netbrah netbrah commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Moves tool_result blocks ahead of any other content within a user-role message in the Anthropic converter, whenever a tool_result is present in that message.

Why it's needed

When a Gemini Content for a user turn contains both a functionResponse and other parts (e.g. accompanying text), the converter emitted the resulting content blocks in whatever order the source parts happened to arrive in — it never guaranteed tool_result blocks come first. If a text part preceded the functionResponse part within the same Content, the resulting Anthropic user message put text before tool_result.

Live-verified against the real Anthropic Messages API: sending a user message shaped [{type:'text', ...}, {type:'tool_result', ...}] right after an assistant tool_use produces HTTP 400: `tool_use` ids were found without `tool_result` blocks immediately after: <id>. Each `tool_use` block must have a corresponding `tool_result` block in the next message. Anthropic doesn't scan past a leading non-tool_result block to find the result later in the same message — it must be first.

Worse: cleanOrphanedToolCalls's own seenNonToolResult gate (a separate, pre-existing defensive check against exactly this ordering rule) reacts to the misordering by treating the tool_result as if it were never found at all — silently discarding both the tool_result and its paired tool_use rather than fixing the order. The previous behavior wasn't even a consistent 400, it was silent data loss of the tool call and its result — confirmed by the existing test this PR rewrites, which was named drops tool results that do not lead user content and pinned exactly that discard as expected.

Reviewer Test Plan

How to verify

  1. npx vitest run packages/core/src/core/anthropicContentGenerator/converter.test.ts — see the rewritten reorders a tool_result ahead of other content in the same message rather than dropping it test (was drops tool results that do not lead user content, now asserts reorder-and-preserve instead of silent discard) and the new preserves relative order among multiple tool_result blocks when reordering ahead of text test.
  2. Live proxy round-trip (what I ran locally): drove the real converter with a text-before-functionResponse Gemini Content, confirmed the resulting message reorders to [tool_result, text], and sent that body to the real API — HTTP 200 (previously HTTP 400 for the unreordered shape, confirmed separately).

Evidence (Before & After)

N/A — no UI surface; behavior change is in the outbound Anthropic request body. See the HTTP 400 → 200 transcript above and the rewritten test's before/after message shapes.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests only (vitest); live verification via a real HTTPS call to Anthropic's Messages API through a corporate LiteLLM proxy in front of Vertex AI (no local sandbox/Docker involved).

Risk & Scope

  • Main risk or tradeoff: reorders (not just filters) content blocks in any user message that carries a tool_result, which changes the wire block order for that message. Array.prototype.sort is spec-guaranteed stable since ES2019, so relative order among blocks of the same type is preserved.
  • Not validated / out of scope: only the Anthropic-native wire path is touched; OpenAI/Chat and Gemini-native converters have their own block ordering and are not affected by this change.
  • Breaking changes / migration notes: none. This only fixes a shape that previously either 400'd or was silently discarded by a separate defensive check — there is no matching "working" behavior being changed.

Linked Issues

Fixes #8161

中文说明

本 PR 做了什么

在 Anthropic 转换器中,只要某条 user 角色消息里出现了 tool_result,就把 tool_result 块移到该消息其他内容之前。

为什么需要这个改动

当某个 user 轮次的 Gemini Content 同时包含 functionResponse 和其他 part(例如附带的文本)时,转换器会按源 part 到达的原始顺序输出对应的内容块——从不保证 tool_result 块排在最前。如果文本 part 在同一个 Content 中出现在 functionResponse part 之前,最终生成的 Anthropic user 消息就会把文本排在 tool_result 之前。

已针对真实 Anthropic Messages API 做了 live 验证:在一个 assistant tool_use 之后,发送形如 [{type:'text', ...}, {type:'tool_result', ...}] 的 user 消息会产生 HTTP 400:`tool_use` ids were found without `tool_result` blocks immediately after: <id>. Each `tool_use` block must have a corresponding `tool_result` block in the next message.。Anthropic 不会跳过一个排在最前的非 tool_result 块去寻找同一消息里靠后的结果——它必须排在最前。

更糟的是:cleanOrphanedToolCalls 自身的 seenNonToolResult 门控(一个针对该排序规则的、独立存在的既有防御检查)在遇到顺序错误时,会把该 tool_result 当作根本没有出现过,从而静默地把tool_result 及其配对的 tool_use 一并丢弃,而不是修正顺序。此前的行为甚至连稳定的 400 都算不上,而是工具调用及其结果的静默数据丢失——本 PR 重写的既有测试(原名 drops tool results that do not lead user content)恰好把这种丢弃行为断言为"预期",印证了这一点。

Reviewer 测试计划

如何验证

  1. npx vitest run packages/core/src/core/anthropicContentGenerator/converter.test.ts——查看重写后的测试 reorders a tool_result ahead of other content in the same message rather than dropping it(原名 drops tool results that do not lead user content,现在断言的是重排并保留,而非静默丢弃),以及新增的 preserves relative order among multiple tool_result blocks when reordering ahead of text 测试。
  2. Live 代理往返(我本地运行的验证):用一个"文本先于 functionResponse"的 Gemini Content 驱动真实转换器,确认生成的消息被重排为 [tool_result, text],并将该请求体发往真实 API——HTTP 200(此前对未重排的形态单独确认过是 HTTP 400)。

证据(前后对比)

不适用——没有 UI 界面;行为变化体现在发往 Anthropic 的出站请求体中。见上方 HTTP 400 → 200 的记录,以及重写测试中的前后消息形态。

测试环境

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

运行环境(可选)

仅单元测试(vitest);live 验证是通过企业 LiteLLM 代理(Vertex AI 后端)对 Anthropic Messages API 发起的真实 HTTPS 调用(未涉及本地沙箱/Docker)。

风险与范围

  • 主要风险或权衡:对任何携带 tool_result 的 user 消息都会重新排序(而非仅过滤)内容块,改变该消息在 wire 上的块顺序。Array.prototype.sort 自 ES2019 起规范保证稳定,因此同类型块之间的相对顺序会被保留。
  • 未验证 / 范围之外:仅改动了 Anthropic 原生 wire 路径;OpenAI/Chat 和 Gemini 原生转换器有各自的块排序逻辑,不受本改动影响。
  • 破坏性变更 / 迁移说明:无。本改动只修复了此前要么 400、要么被另一个防御检查静默丢弃的形态——不存在对应的"原本可用"行为被改变。

关联 Issue

Fixes #8161

…essages

Fixes QwenLM#8161

When a Gemini Content for a user turn contains both a functionResponse
and other parts (e.g. accompanying text), the converter emitted the
resulting content blocks in whatever order the source parts happened to
arrive in -- it never guaranteed tool_result blocks come first. If a
text part preceded the functionResponse part within the same Content, the
resulting Anthropic user message put text before tool_result.

Live-verified against the real Anthropic Messages API: sending a user
message shaped [{type:'text', ...}, {type:'tool_result', ...}] right
after an assistant tool_use produces HTTP 400: "`tool_use` ids were found
without `tool_result` blocks immediately after: <id>. Each `tool_use`
block must have a corresponding `tool_result` block in the next
message." Anthropic doesn't scan past a leading non-tool_result block to
find the result later in the same message -- it must be first.

Worse: cleanOrphanedToolCalls's own "seenNonToolResult" gate (a separate,
pre-existing defensive check against exactly this ordering rule) reacts
to the misordering by treating the tool_result as if it were never found
at all, silently discarding BOTH the tool_result and its paired tool_use
rather than fixing the order. So the previous behavior wasn't even a
consistent 400 -- it was silent data loss of the tool call and its
result.

Fix: when a user-role message contains any tool_result blocks, a stable
sort moves them ahead of any other content in that message, preserving
the relative order of multiple tool_result blocks against each other.
This runs before cleanOrphanedToolCalls, so its ordering gate now
recognizes the pairing correctly instead of discarding it.

Verification:
- Rewrote the existing test that pinned the silent-discard behavior
  ("drops tool results that do not lead user content") to assert the
  correct reorder-and-preserve behavior instead.
- Added a new test for relative-order preservation across multiple
  tool_result blocks being reordered together.
- Full anthropicContentGenerator/ suite: 193 tests pass (was 192; net +1
  test).
- Live proxy verification: the previously-400ing wire shape now returns
  HTTP 200 with the reordered body.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 30, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on d2ee60b and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— d2ee60b 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: headings differ slightly from the template (## What / ## Fix / ## Verification instead of the prescribed sections), but the substance is all there — what, why, verification, and linked issue. Not worth a round-trip for a 2-file fix.

Problem: observed bug with solid evidence. Issue #8161 includes a live API reproduction — HTTP 400 from the real Anthropic Messages API with the exact error message. The issue was triaged and confirmed in source. The interaction with cleanOrphanedToolCalls's seenNonToolResult gate (silently discarding both the tool_result and its paired tool_use) is a real consequence, not theoretical.

Direction: aligned. This is a straightforward ordering bug in the Anthropic converter — the converter emits blocks in Gemini part-iteration order, but the Anthropic API requires tool_result blocks to lead the user message. Clearly within scope.

Size: 18 production lines (converter.ts), 64 test lines (converter.test.ts). Well below any threshold.

Approach: the scope feels right — a stable sort in processContent that moves tool_result blocks ahead of other content in user messages, running before cleanOrphanedToolCalls so its ordering gate recognizes the pairing. Minimal, focused, no drive-by changes.

Risk: no elevated risk signals.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板:标题与模板略有不同(## What / ## Fix / ## Verification 而非规定的章节),但实质内容齐全——做了什么、为什么、如何验证、关联 issue。对于一个 2 文件的修复,不值得为此来回。

问题:已观测到的 bug,证据充分。Issue #8161 包含真实 API 复现——Anthropic Messages API 返回 HTTP 400 及确切错误信息。Issue 已经过 triage 并在源码中确认。与 cleanOrphanedToolCallsseenNonToolResult 门控的交互(静默丢弃 tool_result 及其配对的 tool_use)是真实后果,非理论性的。

方向:对齐。这是 Anthropic 转换器中的排序 bug——转换器按 Gemini part 迭代顺序输出块,但 Anthropic API 要求 tool_result 块在 user 消息中排在最前。明确在范围内。

规模:18 行生产代码(converter.ts),64 行测试代码(converter.test.ts)。远低于任何阈值。

方案:范围合理——在 processContent 中进行稳定排序,将 user 消息中的 tool_result 块移到其他内容之前,在 cleanOrphanedToolCalls 之前运行,使其门控能正确识别配对。最小化、聚焦、无顺手改动。

风险:无升级风险信号。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at d2ee60b2fd1b3bea1f7e0adc74f582514455bf74 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the problem (Anthropic requires tool_result blocks to lead user messages, but the converter emits them in Gemini part-iteration order), I would add a stable sort in processContent that moves tool_result blocks to the front of user-message content arrays, before cleanOrphanedToolCalls runs. Update the existing test that pins the discard behavior, and add a multi-tool_result ordering test.

Comparison with the diff: the PR does exactly this. The implementation is clean and correct:

  • The sort comparator (tool_result vs non-tool_result → −1/+1, same type → 0) is a textbook stable partition. JavaScript's Array.prototype.sort is spec-guaranteed stable since ES2019, so relative order within each group is preserved.
  • The guard (role === 'user' && contentBlocks.some(b => b.type === 'tool_result')) is tight — no unnecessary work on assistant messages or messages without tool results.
  • Placement is correct: inside processContent, before the post-processing pipeline (cleanOrphanedToolCalls, mergeConsecutiveUserMessages, etc.), so the seenNonToolResult gate in cleanOrphanedToolCalls now sees tool_result blocks first and correctly validates the pairing instead of discarding both sides.
  • The 8-line comment block explaining the Anthropic ordering requirement and the stable-sort rationale is warranted — this is a non-obvious API constraint that a future maintainer would need to understand.

Tests: the rewritten test (reorders a tool_result ahead of other content in the same message rather than dropping it) now asserts the full message sequence including the preceding user and assistant messages, which is stronger than the old assertion. The new multi-tool_result test verifies relative-order preservation. Both are well-constructed.

No correctness bugs, security holes, regressions, or convention violations found.

Testing

Final CI results for d2ee60b (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

CI is still running — the ubuntu unit test suite is in progress. macOS/Windows tests and integration tests are skipped pending fork-PR approval. The precheck-pr gate passed.

Sandboxed verification would settle the remaining gap: @qwen-code /verify — that the reordered wire shape actually returns HTTP 200 from the Anthropic API (the author's live-proxy claim) is not observable from unit tests alone, and this is a sponsored run since the author lacks write access. A maintainer's @qwen-code /verify comment approves the head it was written against; the run carries a pre-execution risk screen and a full workspace wipe. Read the resulting report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

独立方案: 给定问题(Anthropic 要求 tool_result 块在 user 消息中排在最前,但转换器按 Gemini part 迭代顺序输出),我会在 processContent 中添加一个稳定排序,将 tool_result 块移到 user 消息内容数组的前面,在 cleanOrphanedToolCalls 运行之前。更新固定丢弃行为的现有测试,并添加多 tool_result 排序测试。

与 diff 的比较: PR 完全这样做了。实现干净且正确:

  • 排序比较器(tool_result vs 非 tool_result → −1/+1,相同类型 → 0)是教科书式的稳定分区。JavaScript 的 Array.prototype.sort 自 ES2019 起规范保证稳定,因此每组内的相对顺序得以保留。
  • 守卫条件(role === 'user' && contentBlocks.some(b => b.type === 'tool_result'))很精确——不会对 assistant 消息或没有 tool result 的消息做不必要的工作。
  • 位置正确:在 processContent 内部,在后处理管线(cleanOrphanedToolCallsmergeConsecutiveUserMessages 等)之前,因此 cleanOrphanedToolCalls 中的 seenNonToolResult 门控现在先看到 tool_result 块,正确验证配对而不是丢弃双方。

测试: 重写的测试现在断言完整的消息序列(包括前面的 user 和 assistant 消息),比旧的断言更强。新的多 tool_result 测试验证了相对顺序保留。两者构造良好。

未发现正确性 bug、安全漏洞、回归或规范违反。

测试

CI 仍在运行——ubuntu 单元测试套件进行中。macOS/Windows 测试和集成测试因 fork PR 需要审批而跳过。precheck-pr 门控已通过。

沙盒验证可以解决剩余差距:@qwen-code /verify——重新排序后的 wire shape 是否真的从 Anthropic API 返回 HTTP 200(作者的 live-proxy 声明)无法仅从单元测试中观察到。由于作者没有写入权限,这是一次赞助运行。维护者的 @qwen-code /verify 评论会批准其编写时的 head;该运行带有预执行风险筛查和完整工作区擦除。请以与 fork 自身 CI 日志相同的怀疑态度阅读生成的报告。

Qwen Code · qwen3.8-max-preview

Reviewed at d2ee60b2fd1b3bea1f7e0adc74f582514455bf74 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal fix that exactly matches the independent proposal; good tests; only reservation is CI hasn't settled yet.

This is a textbook small bug fix. The problem is real (live-verified HTTP 400 from the Anthropic API), the root cause is correctly identified (part-iteration order in processContent + the seenNonToolResult gate in cleanOrphanedToolCalls), and the fix is the simplest thing that works — a stable partition sort, 18 lines of production code, guarded to only run when needed. The tests pin the correct behavior and would catch a regression.

The PR carries no unrelated changes, no over-abstraction, no scope creep. If I had to maintain this in six months, the comment block and the test names would tell me exactly why the sort is there.

Approval deferred until CI lands green on d2ee60b2fd1b3bea1f7e0adc74f582514455bf74 — the ubuntu suite is in progress and macOS/Windows are pending fork-PR approval. If CI surfaces a failure, the approval should be revisited.

中文说明

置信度:4/5 —— 干净、最小化的修复,与独立方案完全吻合;测试良好;唯一的保留是 CI 尚未完成。

这是一个教科书式的小型 bug 修复。问题是真实的(Anthropic API 返回 HTTP 400 的实际验证),根因正确识别(processContent 中的 part 迭代顺序 + cleanOrphanedToolCalls 中的 seenNonToolResult 门控),修复是最简可行方案——稳定分区排序,18 行生产代码,仅在需要时运行。测试固定了正确行为,能捕获回归。

PR 没有无关改动、没有过度抽象、没有范围蔓延。如果六个月后维护这段代码,注释块和测试名称会准确告诉我排序存在的原因。

批准推迟至 CI 在 d2ee60b2fd1b3bea1f7e0adc74f582514455bf74 上全部通过——ubuntu 套件进行中,macOS/Windows 等待 fork PR 审批。如果 CI 出现失败,应重新审视批准。

Qwen Code · qwen3.8-max-preview

Reviewed at d2ee60b2fd1b3bea1f7e0adc74f582514455bf74 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship — CI landed green after the review. ✅

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — PR #8165

I verified this locally against real Anthropic-protocol servers rather than only reading the diff. Verdict: confirmed, recommend merge. Summary of what I found, for merge reference.

Base for every A/B below: PR head d2ee60b2 vs merge-base 584f6a4b; the only thing swapped between runs is packages/core/src/core/anthropicContentGenerator/converter.ts (verified by grepping the built dist for a string unique to the fix before trusting either run).


1. The mis-ordered shape is produced by qwen-code itself, not just by hand-built history

The PR calls the "text part before functionResponse" shape plausible. It is stronger than that — the ACP / daemon tool loop emits it today:

Session.ts:8162  responseParts = convertToFunctionResponse(...)      // [ {functionResponse} ]
Session.ts:8242  responseParts.push({ text: additionalContext })     // [ {fr}, {text} ]  ← PostToolUse hook
Session.ts:6530  parts: finalized.flatMap((e) => e.responseParts)    // [ fr(t1), text, fr(t2) ]  ← text before fr(t2)
Session.ts:4601  message = { role: 'user', parts }                   // sent as ONE Gemini Content

Runtime-confirmed rather than only read: I drove the real Session.prompt() through the repo's own ACP test harness with a PostToolUse hook returning additionalContext and a two-tool-call model turn. The parts of the next user message came out as:

[ functionResponse(call-1), text(HOOK_CTX_MARKER), functionResponse(call-2), text(HOOK_CTX_MARKER) ]

The core TUI / non-interactive scheduler is not affected: coreToolScheduler.ts:1028 folds hook context into functionResponse.response.output instead of pushing a sibling text part. So today's exposure is ACP / daemon users (IDE, Zed, SDK) on an Anthropic-protocol provider with a PostToolUse hook, plus any externally supplied history.

ACP path

2. Real end-to-end A/B — production converter, real models, real network

Harness: the production AnthropicContentGenerator from the built core dist → a local logging proxy that records the exact outbound body → a real Anthropic-protocol endpoint. Same history, same model on both sides.

history shape build outbound body model answer
[text, functionResponse] main 584f6a4b 1 user message, 3 text blocks — tool_use and tool_result gone model re-issues the tool call
[text, functionResponse] PR d2ee60b2 3 messages, tool_result first 41.7kPa
[fr(t1), text, fr(t2)] (the ACP shape) main 584f6a4b tool_use toolu_e2e_2 and its tool_result gone PRESSURE_READING=41.7kPa TEMP_READING=MISSING
[fr(t1), text, fr(t2)] (the ACP shape) PR d2ee60b2 both pairs intact, both tool_results first PRESSURE_READING=41.7kPa TEMP_READING=88.3C

Reproduced identically on two real providers — api.kimi.com/coding (kimi-k3) and open.bigmodel.cn/api/anthropic (glm-4.7).

end-to-end A/B

This is the part that matters most for merge: on main the failure is not a loud 400, it is a silent partial loss of tool calls and their results, and the model then answers from a history it doesn't know is incomplete (re-calls the tool, or reports MISSING).

3. Why the ordering requirement is real (probe sent straight to the server, qwen-code not involved)

  • [{text}, {tool_result}]HTTP 400 invalid_request_error: "an assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id' …"
  • [{tool_result}, {text}]HTTP 200

That is on a strict server (api.kimi.com/coding). open.bigmodel.cn/api/anthropic accepts both — but on a lenient server the damage is exactly the client-side drop in §2, so the mis-ordered shape never works as intended either way. This independently corroborates the author's Anthropic-API transcript.

4. Negative control on the PR's own tests

Reverting only converter.ts to the merge-base while keeping the PR's tests flips exactly the two intended tests RED (71 pass / 2 fail):

  • reorders a tool_result ahead of other content in the same message rather than dropping it
  • preserves relative order among multiple tool_result blocks when reordering ahead of text

Restore → 73/73, and 193/193 for the whole anthropicContentGenerator/ suite. The new tests are not vacuous.

5. Randomized invariant + completeness of the fix

3 000 synthetic histories with shuffled part order (7 489 tool calls), asserting "in every emitted user message, no tool_result block appears after a non-tool_result block":

build tool_use emitted tool_result emitted pairs lost ordering violations
main 584f6a4b 3 963 / 7 489 3 963 / 7 489 3 526 (47.1 %) 0
PR d2ee60b2 7 489 / 7 489 7 489 / 7 489 0 0

main satisfies the invariant only because the offending tool_result — and its paired tool_use — were deleted first. That is the "silent data loss" the PR describes, quantified.

Coverage is complete rather than incidental: only three places in the converter build user-message content — converter.ts:406 (plain string), converter.ts:492 (processContent, now sorted), and converter.ts:1216 (mergeConsecutiveUserMessages, which already partitions tool_result first). Nothing downstream of processContent can re-introduce the bad order.

6. Scope claim checked

The PR states the OpenAI/Chat path is unaffected. Confirmed by probe: the same Gemini history through convertGeminiRequestToOpenAI yields a role:"tool" message plus a separate user text message — nothing dropped, no ordering issue.

7. Gates

eslint --max-warnings 0 clean on both changed files · tsc --build packages/core clean · 193/193 core Anthropic suites · CI Test (ubuntu-latest, Node 22.x) green.

evidence


Non-blocking notes (not merge blockers)

  1. Style consistency. mergeConsecutiveUserMessages (converter.ts:1216-1223) already does this exact hoist with a two-filter partition. A partition here would read the same way as the existing code and would not depend on sort stability at all. Array.prototype.sort has been spec-stable since ES2019, so the current form is correct — this is taste, not a defect.
  2. Cache breakpoint edge case. addCacheControlToMessages only sets the per-turn breakpoint when the last block of the last user message is text or tool_result. If a user Content ever carried a sibling inlineData part alongside a functionResponse, reordering would move that image last and the breakpoint would be skipped for that turn. Not reachable today — convertToFunctionResponse nests media inside the functionResponse — and the pre-existing mergeConsecutiveUserMessages hoist has the same property, so this is a note for the record, not a regression.
  3. Possible follow-up (separate PR). Session.ts:8242 could fold PostToolUse additionalContext into the functionResponse output the way coreToolScheduler.ts:1028 does, so the ACP history is well-formed at the source too. This PR is still the right defensive layer regardless of that.
中文说明

PR #8165 本地验证报告

我在本地针对真实的 Anthropic 协议服务端做了验证,而不是只读 diff。结论:问题与修复均已确认,建议合并。 以下是验证结果,供合并参考。

下文所有 A/B 的基准:PR head d2ee60b2 对比 merge-base 584f6a4b;两次运行之间只替换 packages/core/src/core/anthropicContentGenerator/converter.ts(在采信任何一次结果之前,先用该修复独有的字符串 grep 构建产物 dist,确认切换确实生效)。


1. 这种乱序形态是 qwen-code 自己产出的,并非只能靠手工构造历史

PR 把「text part 排在 functionResponse 之前」这种形态描述为可能出现。实际上比这更强——ACP / daemon 的工具循环现在就会产出它:

Session.ts:8162  responseParts = convertToFunctionResponse(...)      // [ {functionResponse} ]
Session.ts:8242  responseParts.push({ text: additionalContext })     // [ {fr}, {text} ]  ← PostToolUse hook
Session.ts:6530  parts: finalized.flatMap((e) => e.responseParts)    // [ fr(t1), text, fr(t2) ]  ← text 排在 fr(t2) 之前
Session.ts:4601  message = { role: 'user', parts }                   // 作为同一个 Gemini Content 发出

这不是只读代码得出的推断,而是运行时确认:我用仓库自带的 ACP 测试装置驱动了真实的 Session.prompt(),配置一个返回 additionalContextPostToolUse hook,并让模型在一轮中发起两次工具调用。下一条 user 消息的 parts 实际输出为:

[ functionResponse(call-1), text(HOOK_CTX_MARKER), functionResponse(call-2), text(HOOK_CTX_MARKER) ]

core 的 TUI / 非交互调度器不受影响coreToolScheduler.ts:1028 是把 hook 上下文并入 functionResponse.response.output,而不是 push 一个兄弟 text part。所以当前的影响面是:使用 Anthropic 协议 provider、并配置了 PostToolUse hook 的 ACP / daemon 用户(IDE、Zed、SDK),以及任何由外部传入的历史。

ACP 路径

2. 真实端到端 A/B——生产环境的转换器、真实模型、真实网络

装置:从构建产物加载生产环境的 AnthropicContentGenerator → 本地日志代理(记录实际出站 body)→ 真实的 Anthropic 协议服务端。两侧历史相同、模型相同。

历史形态 构建 出站 body 模型回答
[text, functionResponse] main 584f6a4b 1 条 user 消息、3 个 text 块——tool_use tool_result 都不见了 模型重新发起工具调用
[text, functionResponse] PR d2ee60b2 3 条消息,tool_result 排在最前 41.7kPa
[fr(t1), text, fr(t2)](ACP 形态) main 584f6a4b tool_use toolu_e2e_2 及其 tool_result 都不见了 PRESSURE_READING=41.7kPa TEMP_READING=MISSING
[fr(t1), text, fr(t2)](ACP 形态) PR d2ee60b2 两对都完整保留,两个 tool_result 都排在最前 PRESSURE_READING=41.7kPa TEMP_READING=88.3C

在两个真实 provider 上结果一致——api.kimi.com/coding(kimi-k3)与 open.bigmodel.cn/api/anthropic(glm-4.7)。

端到端 A/B

这一点对是否合并最关键:在 main 上,故障并不是一个显式的 400,而是工具调用及其结果被静默地部分丢弃,模型随后基于一份自己并不知道已残缺的历史作答(要么重新调用工具,要么报告 MISSING)。

3. 排序要求确实存在(直接打给服务端的探针,不经过 qwen-code)

  • [{text}, {tool_result}]HTTP 400 invalid_request_error"an assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id' …"
  • [{tool_result}, {text}]HTTP 200

这是在严格的服务端上(api.kimi.com/coding)。open.bigmodel.cn/api/anthropic 两种都接受——但在宽松服务端上,损失就是 §2 里的客户端丢弃,所以无论哪种情况,乱序形态都不会按预期工作。这独立佐证了作者给出的 Anthropic API 记录。

4. 对 PR 自带测试做反向对照

只把 converter.ts 回退到 merge-base、保留 PR 的测试,恰好让预期的两个测试变红(71 通过 / 2 失败):

  • reorders a tool_result ahead of other content in the same message rather than dropping it
  • preserves relative order among multiple tool_result blocks when reordering ahead of text

恢复后 73/73,整个 anthropicContentGenerator/ 套件 193/193。说明新增测试不是空转的。

5. 随机化不变量检查 + 修复的完备性

3 000 个随机 part 顺序的合成历史(7 489 次工具调用),断言*「在每一条输出的 user 消息中,tool_result 块都不会出现在非 tool_result 块之后」*:

构建 输出的 tool_use 输出的 tool_result 丢失的配对 顺序违规
main 584f6a4b 3 963 / 7 489 3 963 / 7 489 3 526(47.1%) 0
PR d2ee60b2 7 489 / 7 489 7 489 / 7 489 0 0

main 之所以也「满足」该不变量,只是因为那些乱序的 tool_result——连同其配对的 tool_use——已经先被删掉了。这正是 PR 所说的「静默数据丢失」,此处给出了量化。

修复的覆盖是完备的、而非碰巧:转换器中只有三处构造 user 消息内容——converter.ts:406(纯字符串)、converter.ts:492processContent,现已排序)、converter.ts:1216mergeConsecutiveUserMessages,本来就把 tool_result 前置)。processContent 之后没有任何环节能重新引入错误顺序。

6. 影响范围声明已核实

PR 声称 OpenAI/Chat 路径不受影响。已用探针确认:同样的 Gemini 历史经 convertGeminiRequestToOpenAI 会产出一条 role:"tool" 消息加一条独立的 user 文本消息——没有丢弃,也不存在顺序问题。

7. 各项门禁

eslint --max-warnings 0 在两个改动文件上均通过 · tsc --build packages/core 通过 · core Anthropic 套件 193/193 · CI Test (ubuntu-latest, Node 22.x) 绿。

佐证


非阻塞备注(都不是合并阻塞项)

  1. 风格一致性。 mergeConsecutiveUserMessagesconverter.ts:1216-1223)已经用两次 filter 的分区写法做了同样的前置。这里改成分区会与既有代码读起来一致,也完全不依赖排序稳定性。Array.prototype.sort 自 ES2019 起规范保证稳定,因此当前写法是正确的——这属于取舍,不是缺陷。
  2. 缓存断点的边界情况。 addCacheControlToMessages 只在最后一条 user 消息的最后一个块是 texttool_result 时才设置每轮缓存断点。若某个 user Content 出现与 functionResponse 并列的 inlineData 兄弟 part,重排会把该图片挪到最后,这一轮的断点就会被跳过。当前不可达——convertToFunctionResponse 会把媒体嵌套进 functionResponse 内部——且既有的 mergeConsecutiveUserMessages 前置逻辑也有同样性质,所以这只是备案,不是回归。
  3. 可能的后续(另开 PR)。 Session.ts:8242 可以像 coreToolScheduler.ts:1028 那样,把 PostToolUseadditionalContext 并入 functionResponse 的 output,让 ACP 历史在源头就是良构的。无论是否做这件事,本 PR 作为防御层依然是正确的。

🤖 Claude Code · Claude Opus 5

@wenshao
wenshao added this pull request to the merge queue Jul 31, 2026
Merged via the queue into QwenLM:main with commit 5d2d792 Jul 31, 2026
69 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Anthropic converter: tool_result blocks not guaranteed first in mixed-content user messages

3 participants