Skip to content

fix(anthropic): dedup duplicate tool_result blocks sharing a tool_use_id - #8163

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
netbrah:fix/anthropic-trailing-tool-use-orphan
Aug 1, 2026
Merged

fix(anthropic): dedup duplicate tool_result blocks sharing a tool_use_id#8163
wenshao merged 4 commits into
QwenLM:mainfrom
netbrah:fix/anthropic-trailing-tool-use-orphan

Conversation

@netbrah

@netbrah netbrah commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #8160

What

cleanOrphanedToolCalls (and the mergeConsecutiveUserMessages pass that runs after it) had no logic to deduplicate tool_result blocks sharing the same tool_use_id. Anthropic rejects a message containing more than one tool_result for the same tool_use_id with HTTP 400 each tool_use must have a single result, and a duplicate can happen when a tool call's result gets recorded twice in history (a retried conversion pass, or a history source that double-appends a function response).

Fix

cleanOrphanedToolCalls now keeps only the first tool_result seen for a given tool_use_id within a message and drops later duplicates. A second, less obvious spot needed the same guard: mergeConsecutiveUserMessages runs after cleanOrphanedToolCalls and can combine two originally-separate user messages that each independently carried a valid tool_result for the same tool_use_id — without a second dedup pass at the merge site, the merge would resurface the exact duplicate shape the first fix removes. Both sites now share the same first-wins dedup.

Verification

  • Three new tests in converter.test.ts: single-message duplicate, duplicate alongside a distinct id, and the cross-message duplicate surfaced by the merge step.
  • Live-verified against the real Anthropic Messages API (via our corporate LiteLLM proxy in front of Vertex, model claude-sonnet-4-6): a message with two tool_result blocks sharing one tool_use_id 400s with each tool_use must have a single result. Found multiple \tool_result` blocks with id: dup_call; the deduplicated single-tool_result` shape returns HTTP 200.
  • tsc --noEmit -p packages/core/tsconfig.json and eslint clean for touched files.

Independently confirmed by maintainer review (@wenshao): built both worktrees, ran an A/B against the merge-base, drove an independent converter probe, and POSTed the exact bytes each branch emits to two live Anthropic-protocol endpoints (api.deepseek.com/anthropic, open.bigmodel.cn/api/anthropic). Confirmed the dedup fix turns a live 400 into a 200 with no collateral changes to the genuine-orphan/normal-round-trip/id-reuse cases, and confirmed both hunks (same-message dedup, cross-message dedup at the merge site) are independently load-bearing — reverting either alone regresses distinct test cases.

(Earlier revisions of this PR also included a trailing-tool_use preservation fix; that content has since landed on main via #8164, so it's no longer part of this PR's diff. This PR is now dedup-only.)

Linked Issues

Fixes #8160

中文说明

Fixes #8160

问题

cleanOrphanedToolCalls(以及在其之后运行的 mergeConsecutiveUserMessages)没有任何逻辑会对共享同一个 tool_use_idtool_result 块去重。当一条消息中出现多个针对同一个 tool_use_idtool_result 时,Anthropic 会返回 HTTP 400 错误 each tool_use must have a single result;而当某次工具调用的结果在历史记录中被重复记录时(例如一次被重试的转换过程,或者某个历史来源重复追加了同一个函数响应),就可能出现这种重复。

修复

cleanOrphanedToolCalls 现在只保留同一条消息内针对同一个 tool_use_id 出现的第一个 tool_result,并丢弃之后的重复项。还有一个不那么明显的地方也需要同样的保护:mergeConsecutiveUserMessages 会在 cleanOrphanedToolCalls 之后运行,它可能会把两条原本独立、各自都携带了一个(各自都合法的)同一 tool_use_idtool_result 的用户消息合并在一起——如果合并处没有再做一次去重,合并后的消息就会重新出现第一处修复本应消除的重复结构。现在这两处都共用同一套"先到先得"的去重逻辑。

验证

  • converter.test.ts 中新增了三个测试:单条消息内的重复、重复项与另一个不同 id 并存、以及由合并步骤暴露出来的跨消息重复。
  • 已针对真实的 Anthropic Messages API 进行了实测验证(通过我们公司架设在 Vertex 之前的 LiteLLM 代理,模型为 claude-sonnet-4-6):一条消息中包含两个共享同一个 tool_use_idtool_result 块会返回 400 错误 each tool_use must have a single result. Found multiple \tool_result` blocks with id: dup_call;去重后只保留一个 tool_result` 的请求则返回 HTTP 200。
  • 对改动涉及的文件运行 tsc --noEmit -p packages/core/tsconfig.json 与 eslint,均无报错。

已由维护者审查独立确认@wenshao):构建了两个 worktree,对 merge-base 做了 A/B 测试,运行了独立的转换器探针,并把两个分支各自产生的实际字节 POST 到两个真实的 Anthropic 协议端点(api.deepseek.com/anthropicopen.bigmodel.cn/api/anthropic)。确认去重修复把一个真实的 400 变成了 200,且对真正的孤立调用/正常一轮工具往返/跨轮次 id 复用等场景没有任何副作用;并确认两处改动(同消息内去重、合并处的跨消息去重)都是独立必要的——单独回退任意一处都会导致不同的测试用例回归。

(本 PR 早期版本还包含一个尾部 tool_use 保留修复;该内容已通过 #8164 合并进 main,因此不再是本 PR diff 的一部分。本 PR 现在仅包含去重修复。)

关联 Issue

Fixes #8160

…ssage

Fixes QwenLM#8159

cleanOrphanedToolCalls treated any assistant tool_use with no matching
tool_result as an orphan and stripped it -- including a tool_use in the
very last message, where there is no subsequent message to have found a
result in yet. "No result yet" is not the same as "no result ever": the
tool may simply not have finished executing, or the conversion may be
happening for a reason other than sending the completed turn to
Anthropic (token counting, a resumed/replayed session snapshot, a retry
issued before tool execution completes, ...).

Silently deleting a currently-active tool_use corrupts the assistant's
most recent turn, and the damage compounds when that turn also carries a
signed extended-thinking block: the block's signature is computed over
the full sibling content of the turn, so removing the tool_use next to
it invalidates the signature and replaying the mutated turn produces
Anthropic 400 "thinking blocks in the latest assistant message cannot be
modified" -- a genuinely confusing error for something the client did to
its own outgoing request.

Fix: when an assistant tool_use is in the last message of the array (no
message follows it at all), treat its tool_use blocks as valid/unresolved
rather than scanning for a match that can't exist yet. A tool_use is only
condemned as orphaned when a subsequent message was actually scanned and
found to lack a matching tool_result.

Also fixed one existing test that unintentionally pinned the buggy
behavior as expected output ("cleans orphaned tool_use blocks without
matching tool_result" used a fixture with no subsequent message at all,
which is the trailing case, not a genuine orphan) -- added a real next
message with unrelated content so it now exercises an actual orphan.
Added a new regression test for the trailing case.

Verification:
- New/updated unit tests in converter.test.ts (73 tests, was 72).
- Full anthropicContentGenerator/ suite: 193 tests pass.
- 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 finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: headings don't match the repo template exactly (## What / ## Fix / ## Verification instead of ## What this PR does / ## Why it's needed / ## Reviewer Test Plan), but the content covers everything the template asks for — motivation, fix description, verification evidence, linked issue, and a full Chinese translation. Not blocking.

Problem: observed bug with solid evidence. Linked issue #8160, live API reproduction showing HTTP 400 each tool_use must have a single result turning into 200 after dedup, and independent maintainer confirmation with A/B wire-level testing against two live Anthropic-protocol endpoints. This is about as well-evidenced as a bug report gets.

Direction: clearly aligned. Anthropic's API contract requires exactly one tool_result per tool_use_id, and the converter currently has no guard against duplicates at two independent sites. Claude Code's CHANGELOG shows related tool_result dedup and orphan fixes, confirming this is a real-world problem class for Anthropic-protocol converters.

Size: core paths touched (packages/core/src/**). Production logic: 49 lines (converter.ts, +45/−4). Tests: 229 lines (converter.test.ts, +229). Well under any threshold.

Approach: minimal and focused — two call sites, one shared factory, four new tests. The PR description notes earlier revisions included a trailing-tool_use fix that has since landed via #8164, and the diff confirms this PR is now dedup-only. No scope creep.

Risk: no elevated risk signals — no high-risk paths matched.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板:标题与仓库模板不完全一致(## What / ## Fix / ## Verification 而非 ## What this PR does / ## Why it's needed / ## Reviewer Test Plan),但内容覆盖了模板要求的所有要素——动机、修复说明、验证证据、关联 issue、完整中文翻译。不阻塞。

问题:已观测到的 bug,证据充分。关联 issue #8160,有真实 API 复现(HTTP 400 each tool_use must have a single result 去重后变为 200),维护者独立确认并对两个真实 Anthropic 协议端点做了 A/B wire 级测试。

方向:明确对齐。Anthropic API 契约要求每个 tool_use_id 恰好一个 tool_result,转换器目前在两个独立位置缺少去重保护。Claude Code 的 CHANGELOG 中有相关的 tool_result 去重和孤立调用修复,确认这是 Anthropic 协议转换器的真实问题类别。

规模:触及核心路径(packages/core/src/**)。生产逻辑:49 行(converter.ts,+45/−4)。测试:229 行(converter.test.ts,+229)。远低于任何阈值。

方案:最小且聚焦——两个调用点,一个共享工厂函数,四个新测试。PR 描述说明早期版本包含的尾部 tool_use 修复已通过 #8164 合入,diff 确认本 PR 现在仅含去重修复。无范围蔓延。

风险:无升级风险信号——未匹配高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 2b51b893c4d86ab98adf094065bf7ed7e3040d67 · 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 "Anthropic rejects duplicate tool_result blocks for the same tool_use_id", I would add a first-wins dedup filter at the two sites where duplicates can appear — inside cleanOrphanedToolCalls (within a single message) and at the merge site in mergeConsecutiveUserMessages (cross-message duplicates that only become co-located after merging). A small factory returning a closure over a Set keeps the two sites independent (fresh scope per message / per merge) while sharing the logic.

Comparison with the diff: the PR does exactly this. makeToolResultDeduper() is a clean factory — fresh Set per invocation, id-less blocks pass through, first-wins semantics documented with the rationale (observed duplicates are byte-identical). The two call sites are correctly scoped: cleanOrphanedToolCalls creates one deduper per message iteration, mergeConsecutiveUserMessages creates one per merge. No global state, no cross-message leakage — a tool_use_id legitimately reused across independent turns is not affected.

The refactoring of the tool_result filter in cleanOrphanedToolCalls from return !id || validToolResultBlocks.has(b) to the three-line form (!id → true, not in valid set → false, then dedup) is equivalent for the non-duplicate case and adds the dedup guard. The merge-site change replaces the old combined.filter(type === 'tool_result') with a dedup-aware filter, preserving the tool_results-first ordering.

No correctness issues, no security concerns, no convention violations. The doc comment on makeToolResultDeduper explains the why well — the two-site necessity, the first-wins rationale, and the id-less passthrough.

Tests: four new cases covering single-message dup, dup alongside a distinct id, cross-message dup via merge (asserting full merged content including the surviving text block), and three-message accumulation. Good coverage of the edge cases.

No findings. ✅

Testing

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Classify PR ✅ success
label ✅ success

Ubuntu unit tests green. macOS/Windows tests and integration tests were skipped (fork PR CI limitation). The PR's central claim — dedup turns a live Anthropic 400 into a 200 — is behavioural and not fully pinned by unit tests alone, but the maintainer (@wenshao) independently confirmed with A/B wire-level testing against two live Anthropic-protocol endpoints, and a sandboxed /verify run is in progress on this PR.

中文说明

代码审查

独立方案: 给定"Anthropic 拒绝同一 tool_use_id 的重复 tool_result 块",我会在重复可能出现的两个位置添加先到先得的去重过滤器——cleanOrphanedToolCalls 内部(单条消息内)和 mergeConsecutiveUserMessages 的合并处(合并后才共置的跨消息重复)。用一个返回闭包(基于 Set)的小型工厂函数,让两个位置保持独立作用域(每条消息/每次合并一个新实例),同时共享逻辑。

与 diff 的对比: PR 完全这样做了。makeToolResultDeduper() 是一个干净的工厂——每次调用一个新 Set,无 id 的块直接通过,先到先得语义附有理由说明(观察到的重复块逐字节相同)。两个调用点的作用域正确:cleanOrphanedToolCalls 每次消息迭代创建一个去重器,mergeConsecutiveUserMessages 每次合并创建一个。无全局状态,无跨消息泄漏——跨轮次合法复用同一 tool_use_id 不受影响。

无正确性问题、无安全隐患、无规范违反。makeToolResultDeduper 的文档注释很好地解释了"为什么"——两处必要性、先到先得的理由、无 id 块的透传。

测试:四个新用例覆盖单消息重复、重复与不同 id 并存、跨消息合并重复(断言包含存活文本块的完整合并内容)、三消息累积。边缘情况覆盖良好。

无发现。✅

测试

Ubuntu 单元测试通过。macOS/Windows 测试和集成测试被跳过(fork PR CI 限制)。PR 的核心主张——去重将 Anthropic 的 400 变为 200——是行为性的,单靠单元测试不能完全验证,但维护者(@wenshao)已独立通过两个真实 Anthropic 协议端点的 A/B wire 级测试确认,且本 PR 上正在运行沙箱化的 /verify

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; would merge without hesitation.

This is a textbook small fix: real bug, clear reproduction, minimal diff, two-site dedup with a shared factory, four focused tests. The independent proposal I wrote before reading the diff matches the PR's approach almost exactly — I didn't find a simpler path it missed.

What makes this PR stand out is the verification depth. The author live-tested against the real Anthropic API (400 → 200), and the maintainer independently confirmed with A/B wire-level testing against two separate Anthropic-protocol endpoints, verified both hunks are independently load-bearing (reverting either alone regresses distinct test cases), and drove an end-to-end session through a recording proxy to confirm real parallel tool calls pass through the dedup untouched. That's not a box-ticking exercise — it's the kind of evidence that makes approval straightforward.

The code is easy to maintain: makeToolResultDeduper is self-contained, well-documented, and the two call sites are obvious. In six months I'd thank the author, not curse them.

No reservations. Approving. ✅

中文说明

置信度:5/5 —— 每个阶段都干净;毫不犹豫地合并。

这是一个教科书式的小型修复:真实 bug、清晰复现、最小 diff、两处去重共用一个工厂函数、四个聚焦测试。我在阅读 diff 之前写的独立方案与 PR 的方法几乎完全一致——没有找到它遗漏的更简路径。

这个 PR 的突出之处是验证深度。作者对真实 Anthropic API 做了实测(400 → 200),维护者独立确认——对两个独立的 Anthropic 协议端点做了 A/B wire 级测试,验证了两处改动各自都不可省(单独回退任意一处都会导致不同的测试用例回归),并通过记录型代理驱动了完整的端到端会话,确认真实的并行工具调用原封不动地通过了去重逻辑。这不是走过场——这种证据让批准变得顺理成章。

代码易于维护:makeToolResultDeduper 自包含、文档完善,两个调用点一目了然。六个月后我会感谢作者,而不是骂他们。

无保留意见。批准。✅

Qwen Code · qwen3.8-max-preview

Reviewed at 2b51b893c4d86ab98adf094065bf7ed7e3040d67 · 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. ✅

Anthropic rejects a message that contains more than one tool_result
block for the same tool_use_id with HTTP 400 "each tool_use must have
a single result" -- nothing in cleanOrphanedToolCalls (or the merge
pass that follows it) deduplicated tool_result blocks, so a tool call
result recorded twice in history (a retried conversion pass, or a
history source that double-appends a function response) would produce
exactly that malformed shape.

Two sites needed the same guard:

- cleanOrphanedToolCalls: keeps only the first tool_result seen for a
  given tool_use_id within one message, dropping later duplicates.
  This is the primary, most common case.

- mergeConsecutiveUserMessages: runs after cleanOrphanedToolCalls and
  can combine two originally-separate user messages that each
  independently carried a (individually valid) tool_result for the
  same tool_use_id -- cleanOrphanedToolCalls's per-message dedup can't
  see across that boundary, so without a second pass here the merge
  step resurfaces the exact duplicate shape the first fix removes.

Live-verified against the real Anthropic Messages API (via our
corporate LiteLLM proxy in front of Vertex, model claude-sonnet-4-6):
a message with two tool_result blocks sharing one tool_use_id 400s
with "each tool_use must have a single result. Found multiple
`tool_result` blocks with id: dup_call"; the deduplicated
single-tool_result shape returns HTTP 200.

Verification:
- Three new tests in converter.test.ts: single-message duplicate,
  duplicate alongside a distinct id (regression guard against
  over-filtering), and the cross-message duplicate surfaced by
  mergeConsecutiveUserMessages.
- Full anthropicContentGenerator/converter.test.ts suite: 76 tests
  pass (was 73).
- Live proxy verification as described above.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.
@netbrah netbrah changed the title fix(anthropic): don't strip a trailing tool_use with no subsequent message fix(anthropic): don't strip a trailing tool_use and dedup duplicate tool_result blocks Jul 31, 2026

@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.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

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

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification report — built and run locally at c1acc426bc

I built both commits in a worktree off the merge-base (584f6a4bec) and ran the change through unit A/B, an independent converter probe, the real qwen binary against a recording Anthropic mock, and — most importantly — against live Anthropic-protocol servers.

Verdict: the two commits pull in opposite directions.

Commit What it does Live-server result Recommendation
c1acc426bc — dedup duplicate tool_result Removes a shape a real server rejects main → 400, PR → 200 Merge
2ba990bde7 — preserve trailing tool_use Emits a shape a real server rejects main → 200, PR → 400 Hold / rework

1 · The PR's tests are load-bearing (A/B against merge-base)

Same test file, only converter.ts swapped for the merge-base copy. All 4 new/changed assertions fail without the fix, so the tests genuinely pin the new behavior.

A/B unit tests

2 · Independent probe of the real converter (not the PR's test file)

A standalone tsx script driving AnthropicContentConverter.convertGeminiRequestToAnthropic directly, 13 cases, run in both worktrees and diffed. Both halves of the fix do exactly what the description claims, and the genuine-orphan / normal-round-trip / id-reuse cases are byte-identical to main (no collateral damage).

converter probe

3 · The decisive test: the two shapes on the wire

I POSTed the exact bytes each branch emits to two live Anthropic-protocol endpoints.

live API

api.deepseek.com/anthropic (a provider this repo explicitly supports — see the DeepSeek branches in anthropicContentGenerator.ts):

  • main's duplicate-tool_result shape → HTTP 400 messages.2.content.1: each tool_use must have a single result. Found multiple tool_result blocks with id: dup_callexactly the failure the dedup commit fixes. Independently reproduced; the PR's shape returns 200. ✅
  • The PR's preserved trailing tool_use → HTTP 400:
    messages.1:`tool_use` ids were found without `tool_result` blocks immediately after: toolu_live123.
    Each `tool_use` block must have a corresponding `tool_result` block in the next message.
    
    main's stripped shape returns 200.

That second error string is not incidental — it is verbatim the error cleanOrphanedToolCalls was introduced to prevent. It is quoted in this file's own comments (converter.ts:174 and :1016, from #6651 "fix tool_use/tool_result pairing for Anthropic-compatible providers"). The trailing commit re-emits the shape that function exists to eliminate.

open.bigmodel.cn/api/anthropic (GLM) accepted all four shapes — its validator is lenient. So after this commit qwen's output becomes provider-dependent where it was previously universally valid.

4 · Reachability — real binary, recording mock

node scripts/dev.js --yolo -p '…' with ANTHROPIC_BASE_URL pointed at a mock that records every request body and enforces both Anthropic validation rules. Three scenarios: normal tool round-trip, stop_reason=max_tokens on a tool_use turn, and a call to a non-existent tool.

E2E wire capture

No real request ever ends on an assistant tool_use — the agent loop always appends the tool_result before the next send. Reading the code agrees:

  • the max-tokens recovery loop explicitly skips a truncated turn containing a functionCall (geminiChat.ts ~L3046: "Skip recovery when the truncated turn already contains a functionCall");
  • the invalid-stream retry calls popPendingPartialAssistantTurn() before re-sending;
  • fork_turns seeds history that ends on a model turn and then appends the task prompt as a user message (agent.ts ~L1693), so the fork's first request is a genuine-orphan shape, not a trailing one.

So today the trailing change is latent, not an active regression — but it is a landmine: any future path that produces the shape gets a hard 400 where main silently repairs it into a request the model answers by simply re-issuing the tool call.

5 · Two premises in the description that don't hold in this codebase

  • "token counting"AnthropicContentGenerator.countTokens() uses RequestTokenEstimator, not the converter (anthropicContentGenerator.ts:430). The converter's only consumer is buildRequest() → the outgoing HTTP body. There is no non-wire consumer for "unresolved but preserved" to serve: at conversion time the request is either sendable (needs a tool_result) or it is not.
  • "corrupts the assistant's most recent turn"cleanOrphanedToolCalls builds a fresh array ({...message, content: filtered}); session history is never mutated. The effect is scoped to one outgoing payload.

6 · The signature-invalidation case the issue describes is not the case this PR changes

The blast-radius argument (signed thinking next to a stripped tool_usethinking blocks in the latest assistant message cannot be modified) needs the turn to actually reach the wire. The shape that does that is a partially-resolved parallel tool call — signed thinking + tool_use A + tool_use B, with only A resolved. That is a genuine orphan (a later message exists), so this PR leaves it untouched:

contents: user → model[thinking(SIGNED), tool_use A, tool_use B] → user[tool_result A]

main: assistant[thinking(sig=SIGNED_ABC), tool_use(A)]     ← tool_use B stripped next to a signed block
PR:   assistant[thinking(sig=SIGNED_ABC), tool_use(A)]     ← IDENTICAL

Verified byte-identical between the two worktrees. This is the real, reachable bug in the issue's narrative and it is still open after this PR. Fixing that — e.g. dropping the whole turn (or the thinking block) when an orphan is stripped from a signed-thinking turn, rather than mutating its siblings — would deliver the issue's stated goal without touching the trailing case.

7 · Regression checks

Check Result
converter.test.ts on the PR 76/76 pass
anthropicContentGenerator + openaiContentGenerator suites 888/888 pass
Full @qwen-code/qwen-code-core suite 18,532 pass; 2 fail in session-service-writer-lease.test.tsreproduce identically on the merge-base, environmental, unrelated
tsc --noEmit -p packages/core/tsconfig.json clean
eslint + prettier --check on both touched files clean
CI on the PR green

8 · Recommendation

Land c1acc426bc (dedup) as-is. It is a well-found bug — including the sharp second-order catch that mergeConsecutiveUserMessages can re-create the duplicate after cleanOrphanedToolCalls removed it — it is confirmed against a live server, its tests are load-bearing, and dropping a duplicate can never turn a valid request into an invalid one.

Hold 2ba990bde7 (trailing tool_use) until one of these is true:

  1. a concrete caller is identified that converts a trailing-tool_use history and needs the block preserved (I could not find one, and the converter has no non-wire consumer); or
  2. the change is paired with a story for the resulting 400 — because "no result yet" and "sendable to Anthropic" are different predicates, and the converter's output is judged only by the second.

If the goal is to make the pending-tool_use case visible rather than silently repaired, a debugLogger.warn at that branch achieves it at zero wire risk.

Follow-up (not blocking): cleanOrphanedToolCalls in openaiContentGenerator/converter.ts has the identical trailing semantics (validToolCalls requires an adjacent tool response). If the Anthropic side ever changes here, the two converters silently diverge.


中文版报告

维护者本地验证报告 —— 基于 c1acc426bc 实际构建运行

我在 merge-base(584f6a4bec)之上建立 worktree 构建了两个 commit,并依次做了:单测 A/B、独立的转换器探针、用真实 qwen 二进制跑录制型 Anthropic mock,以及最关键的——向真实的 Anthropic 协议服务端发送实际字节

结论:这个 PR 的两个 commit 方向相反。

Commit 作用 真实服务端结果 建议
c1acc426bc —— 去重重复 tool_result 移除了真实服务端会拒绝的形状 main → 400,PR → 200 合并
2ba990bde7 —— 保留尾部 tool_use 产生了真实服务端会拒绝的形状 main → 200,PR → 400 暂缓 / 重做

1 · PR 的测试确实有效(对 merge-base 做 A/B)

只把 converter.ts 换回 merge-base 版本、测试文件不变:4 个新增/修改的断言全部失败。说明测试真正锁住了新行为,不是自我印证。

2 · 独立探针(不使用 PR 自带测试文件)

用一个独立 tsx 脚本直接驱动 convertGeminiRequestToAnthropic,覆盖 13 个用例,在两个 worktree 分别运行并 diff。两处修复的行为与描述完全一致;真正的孤立调用、正常一轮工具往返、跨轮次 id 复用等用例与 main 逐字节相同,没有波及其他路径。

3 · 决定性证据:把两种形状发到真实服务端

我把两个分支各自产生的实际字节分别 POST 到两个真实的 Anthropic 协议端点。

api.deepseek.com/anthropic(本仓库明确支持的 provider,anthropicContentGenerator.ts 里有专门的 DeepSeek 分支):

  • main 的“重复 tool_result”形状 → HTTP 400 each tool_use must have a single result. Found multiple tool_result blocks with id: dup_call —— 正是去重那个 commit 修掉的问题,我独立复现了;PR 的形状返回 200。✅
  • PR 保留下来的尾部 tool_use → HTTP 400:
    messages.1:`tool_use` ids were found without `tool_result` blocks immediately after: toolu_live123.
    Each `tool_use` block must have a corresponding `tool_result` block in the next message.
    
    而 main 剥离后的形状返回 200

第二条错误信息不是巧合:它正是 cleanOrphanedToolCalls 当初被引入所要避免的那个错误,本文件自己的注释里就逐字引用了它(converter.ts:174:1016,来自 #6651 “fix tool_use/tool_result pairing for Anthropic-compatible providers”)。尾部那个 commit 等于把这个函数存在的意义重新放了回去。

open.bigmodel.cn/api/anthropic(GLM)对四种形状全部返回 200,其校验较宽松。也就是说这个 commit 之后,qwen 的输出从“对所有 provider 都合法”变成了“依 provider 而定”。

4 · 可达性 —— 真实二进制 + 录制 mock

node scripts/dev.js --yolo -p '…'ANTHROPIC_BASE_URL 指向一个会记录每个请求体、并强制执行上述两条 Anthropic 校验规则的 mock。三个场景:正常工具往返、tool_use 回合上 stop_reason=max_tokens、模型调用不存在的工具。

没有任何一次真实请求以助手 tool_use 结尾 —— agent 循环总是先补上 tool_result 再发下一次请求。代码阅读结论一致:

  • max_tokens 恢复循环显式跳过functionCall 的截断回合(geminiChat.ts 约 L3046);
  • 非法流重试会先调用 popPendingPartialAssistantTurn() 再重发;
  • fork_turns 播种的历史以 model 回合结尾,随后把 task prompt 作为 user 消息追加agent.ts 约 L1693),所以 fork 的首个请求是“真正的孤立调用”形状,而不是尾部形状。

因此目前尾部这处改动是潜伏的,而非已经生效的回归——但它是一颗地雷:将来任何一条产生该形状的路径都会直接拿到 400,而 main 会静默修复成一个模型只需重新发起工具调用即可继续的合法请求。

5 · 描述中有两个前提在本仓库并不成立

  • “token counting” —— AnthropicContentGenerator.countTokens() 使用的是 RequestTokenEstimator,并不经过转换器(anthropicContentGenerator.ts:430)。转换器唯一的消费者是 buildRequest() → 出站 HTTP body。不存在“保留未解决 tool_use”能服务的非上线消费者:转换发生时,请求要么可发送(就必须有 tool_result),要么不可发送。
  • “破坏助手最近一次回合” —— cleanOrphanedToolCalls 构造的是新数组({...message, content: filtered}),从不修改会话历史。影响仅限于单次出站 payload。

6 · issue 里描述的签名失效场景,恰恰不是本 PR 改动的场景

“签名被破坏”这条论证要成立,该回合必须真的发到线上。真正会发生这种情况的形状是部分完成的并行工具调用——带签名的 thinking + tool_use A + tool_use B,只有 A 有结果。它属于真正的孤立调用(后面确实有消息),因此本 PR 完全没有改到它:

contents: user → model[thinking(SIGNED), tool_use A, tool_use B] → user[tool_result A]

main: assistant[thinking(sig=SIGNED_ABC), tool_use(A)]     ← tool_use B 在带签名的块旁边被剥离
PR:   assistant[thinking(sig=SIGNED_ABC), tool_use(A)]     ← 完全相同

两个 worktree 输出逐字节一致。这才是 issue 叙述中真实且可达的 bug,而它在本 PR 之后依然存在。 若改为修这个(例如:当从带签名 thinking 的回合中剥离孤立调用时,整回合丢弃或连同 thinking 块一起丢弃,而不是只动它的兄弟块),就能达成 issue 的目标,且完全不必碰尾部这个场景。

7 · 回归检查

检查项 结果
PR 上的 converter.test.ts 76/76 通过
anthropicContentGenerator + openaiContentGenerator 两个目录 888/888 通过
@qwen-code/qwen-code-core 全量套件 18,532 通过;2 个失败在 session-service-writer-lease.test.ts在 merge-base 上同样失败,属环境问题,与本 PR 无关
tsc --noEmit -p packages/core/tsconfig.json 无错误
两个改动文件的 eslint + prettier --check 无问题
PR 的 CI 全绿

8 · 建议

c1acc426bc(去重)建议直接合并。 这是一处发现得很到位的 bug——尤其是那个二阶洞察:mergeConsecutiveUserMessages 会把 cleanOrphanedToolCalls 刚去掉的重复再造出来;它已被真实服务端确认,测试有效,而且“丢弃重复项”永远不可能把一个合法请求变成非法请求。

2ba990bde7(尾部 tool_use)建议暂缓,直到满足以下之一:

  1. 找到一个确实会转换“尾部 tool_use 历史”、并且需要保留该块的具体调用方(我没有找到,且转换器不存在非上线消费者);或者
  2. 这处改动配套给出 400 之后的处理方案——因为“还没有结果”和“可以发给 Anthropic”是两个不同的判定,而转换器的输出只受后者检验。

如果目的只是让“存在未解决 tool_use”这件事可见而非被静默修复,在该分支加一条 debugLogger.warn 即可实现,且对线上零风险。

后续(不阻塞): openaiContentGenerator/converter.ts 里的 cleanOrphanedToolCalls 有完全相同的尾部语义(validToolCalls 要求存在相邻的 tool response)。如果 Anthropic 这一侧改了语义,两个转换器就会悄悄产生分歧。


Verification environment: Linux, Node 22, worktrees pr8163 @ c1acc426bc and base @ 584f6a4bec (converter files are identical between the merge-base and current origin/main, so the A/B is clean). Live-server checks used third-party Anthropic-protocol endpoints (DeepSeek, GLM) with synthetic 30-token payloads; I have no api.anthropic.com key on this machine, so the upstream verdict is inferred from the verbatim error text this repo already cites for that provider class.

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Root cause

main's #8164 (fix(anthropic): sanitize tool_use.id / tool_result.tool_use_id) absorbed this PR's first commit (the trailing-tool_use fix for #8159) verbatim — its message says it "merges in the trailing-tool_use fix from #8163". Both branches added the identical guard to cleanOrphanedToolCalls, so converter.ts auto-merged cleanly. The only textual conflict was in converter.test.ts: this PR's two dedup tests and #8164's describe('tool_use id sanitization') block were inserted at the same spot, after the "cleans orphaned tool_result blocks" test.

Textual or semantic

What is load-bearing

  • Dedup keys on the wire tool_use_id (post-sanitization). Safe because resolveToolUseId memoizes source→wire per request (toolIdMap) and makeUniqueToolUseId enforces request-wide uniqueness: duplicate source ids collapse to one wire id (dedup catches them); distinct source ids never collapse (no false dedup).
  • Pipeline order: cleanOrphanedToolCalls (converter.ts:277) runs before mergeConsecutiveUserMessages (:285). The PR's second dedup pass sits at the merge site because the per-message pass cannot see duplicates the merge creates. Do not reorder these or drop the second pass.
  • The trailing guard if (i === messages.length - 1) must stay before the forward scan loop.

What I could not verify

No build/typecheck/tests run (per this command's contract); the sanitization↔dedup interaction was verified by reading, not execution — the PR's CI covers it. Reviewers note: since #8164 already shipped this PR's first commit, the merged tree carries the trailing-tool_use regression test and the "genuine orphan" fixture rewrite exactly once, not twice.

中文说明

根因:main 上的 #8164(tool_use.id 清洗)已原样吸收本 PR 第一个提交(#8159 尾部 tool_use 修复),其提交说明明确写有 "merges in the trailing-tool_use fix from #8163"。两侧向 cleanOrphanedToolCalls 添加了完全相同的守卫,故 converter.ts 自动合并干净。唯一文本冲突在 converter.test.ts:本 PR 的两个去重测试与 #8164 的 sanitization describe 块被插入同一位置。

文本还是语义:测试文件为纯相邻冲突,取并集解决——先放 PR 去重测试,再放 main 的 sanitization describe,相对任一父提交零删除。converter.ts 由 git 自动合并,经人工核验为精确并集:尾部守卫仅一份,PR 两处去重完整保留,并含 #8164 的 id 清洗与 #8165 的 tool_result 前置排序。

关键约束:去重以清洗后的线上 tool_use_id 为键——resolveToolUseId 按请求记忆源→线上映射,makeUniqueToolUseId 保证全请求唯一,故相同源 id 归并(可去重)、不同源 id 绝不归并(不误删)。流水线顺序:cleanOrphanedToolCalls(:277)必须先于 mergeConsecutiveUserMessages(:285),第二处去重位于合并点,正因单消息去重看不到合并产生的重复;不可调序或删除。尾部守卫须位于前向扫描循环之前。

未能验证:按约定未运行构建/测试,清洗与去重的交互仅经阅读核验,由 PR 自身 CI 覆盖。另注意:#8164 既已包含本 PR 第一提交内容,合并后尾部 tool_use 回归测试与 fixture 改写各仅一份,不会重复。

qwen-code-dev-bot pushed a commit to netbrah/qwen-code-upstream-pr that referenced this pull request Jul 31, 2026
…M#8164)

* fix(anthropic): don't strip a trailing tool_use with no subsequent message

Fixes QwenLM#8159

cleanOrphanedToolCalls treated any assistant tool_use with no matching
tool_result as an orphan and stripped it -- including a tool_use in the
very last message, where there is no subsequent message to have found a
result in yet. "No result yet" is not the same as "no result ever": the
tool may simply not have finished executing, or the conversion may be
happening for a reason other than sending the completed turn to
Anthropic (token counting, a resumed/replayed session snapshot, a retry
issued before tool execution completes, ...).

Silently deleting a currently-active tool_use corrupts the assistant's
most recent turn, and the damage compounds when that turn also carries a
signed extended-thinking block: the block's signature is computed over
the full sibling content of the turn, so removing the tool_use next to
it invalidates the signature and replaying the mutated turn produces
Anthropic 400 "thinking blocks in the latest assistant message cannot be
modified" -- a genuinely confusing error for something the client did to
its own outgoing request.

Fix: when an assistant tool_use is in the last message of the array (no
message follows it at all), treat its tool_use blocks as valid/unresolved
rather than scanning for a match that can't exist yet. A tool_use is only
condemned as orphaned when a subsequent message was actually scanned and
found to lack a matching tool_result.

Also fixed one existing test that unintentionally pinned the buggy
behavior as expected output ("cleans orphaned tool_use blocks without
matching tool_result" used a fixture with no subsequent message at all,
which is the trailing case, not a genuine orphan) -- added a real next
message with unrelated content so it now exercises an actual orphan.
Added a new regression test for the trailing case.

Verification:
- New/updated unit tests in converter.test.ts (73 tests, was 72).
- Full anthropicContentGenerator/ suite: 193 tests pass.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.

* fix(anthropic): sanitize tool_use.id / tool_result.tool_use_id

Fixes QwenLM#8160

The Anthropic converter passed Part.functionCall.id / FunctionResponse.id
straight through as tool_use.id / tool_result.tool_use_id with only an
empty-string-style fallback, never validating or sanitizing against
Anthropic's accepted character set.

Anthropic validates both fields against ^[a-zA-Z0-9_-]+$ server-side and
rejects anything else -- including the empty string, since `+` requires
at least one character. functionCall.id is the Gemini lingua-franca's own
id field with no such constraint, so it can legitimately carry another
provider's id scheme, a composite/namespaced id, or be entirely absent.

Live-verified against the real Anthropic Messages API (via our corporate
LiteLLM proxy in front of Vertex, model claude-sonnet-4-6):
- A tool_use.id containing characters outside [a-zA-Z0-9_-] (e.g.
  `call:abc.def/ghi?jkl`) 400s: "String should match pattern
  '^[a-zA-Z0-9_-]+$'".
- An empty-string tool_result.tool_use_id (the exact fallback the old
  code emitted) 400s the same way.
- The sanitized replacement (`call_abc_def_ghi_jkl`) round-trips
  successfully (HTTP 200) with the pair still linked.

Fix: resolveToolUseId() sanitizes non-conforming characters to `_`,
generates a real fallback id (never an empty string) when the source is
missing, and memoizes the source id -> sanitized id mapping per request
(toolIdMap) so a tool_use/tool_result pair sharing a source id still
resolves to the same wire id after sanitization. Uniqueness is enforced
across the whole request (usedToolIds) rather than the previous
per-Content-object counter, which fixes a latent collision: two
different Content objects each missing an id previously both started
their local counter at 0, producing duplicate fallback ids like `tool_0`
in two different assistant turns.

State is scoped to a single convertGeminiRequestToAnthropic call (reset
via resetToolIdState) since the converter instance is long-lived across
requests (constructed once per generator, not once per call).

Also merges in the trailing-tool_use fix from QwenLM#8163 (fixes QwenLM#8159): one
of the new tests here needs the model's missing-id tool_use to survive
as the final message in order to observe the generated fallback id,
which depends on that fix. This PR's net diff will shrink to just the
sanitization change once QwenLM#8163 merges.

Verification:
- New tests in converter.test.ts covering: bad-char sanitization with
  linkage preserved, non-empty fallback generation (not empty string),
  no id collisions across two missing-id calls in one request, and
  same-source-id consistency across tool_use/tool_result in different
  messages.
- Full anthropicContentGenerator/ suite: 197 tests pass.
- Live proxy verification as described above.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.

---------

Co-authored-by: Palanisamy, Dinesh <Dinesh.Palanisamy@netapp.com>

@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.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

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

Comment on lines +1520 to +1523
if (!validToolResultBlocks.has(b as object)) return false;
if (seenToolResultIds.has(id)) return false;
seenToolResultIds.add(id);
return true;

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.

[Suggestion] The new per-block dedup drops a duplicate tool_result silently, whereas the existing whole-message drop in this same function logs via debugLogger.debug (line ~1531). — Concrete cost: an oncall engineer investigating why a specific tool result is absent from the Anthropic request finds no trace of the per-block drop in debug logs and must read the converter source and mentally trace the pipeline to discover the dedup. Adding a debug line at each drop site (here and in the parallel mergeConsecutiveUserMessages dedup) matches the file's existing logging pattern. Note the pre-existing per-block orphan drops are also silent, so this is a broader observability improvement rather than a regression introduced here.

Suggested change
if (!validToolResultBlocks.has(b as object)) return false;
if (seenToolResultIds.has(id)) return false;
seenToolResultIds.add(id);
return true;
if (!validToolResultBlocks.has(b as object)) return false;
if (seenToolResultIds.has(id)) {
debugLogger.debug(
`cleanOrphanedToolCalls: dropping duplicate tool_result for tool_use_id=${id}`,
);
return false;
}
seenToolResultIds.add(id);
return true;
中文说明

[Suggestion] 新增的按块去重逻辑会静默丢弃重复的 tool_result,而同一函数中已有的整条消息丢弃逻辑(约第 1531 行)会通过 debugLogger.debug 打日志。— 具体代价:当值班工程师排查“某个工具结果为何没有出现在发往 Anthropic 的请求中”时,在调试日志里找不到这次按块丢弃的任何痕迹,只能去读转换器源码并在脑中追踪整条 pipeline 才能发现这个去重逻辑。在两处丢弃点(此处以及 mergeConsecutiveUserMessages 中对应的去重)各加一行调试日志,与本文件已有的日志模式保持一致。注意:既有的按块孤立调用丢弃同样是静默的,因此这更像是一项整体可观测性改进,而非本次改动引入的回归。

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

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Review

Verdict: the code change is correct and minimal, and I verified both hunks are independently necessary. One thing should be fixed before merge — the title/description no longer match the diff.


1. Title and description describe work that is already on main (must fix — metadata, not code)

gh pr diff 8163 diffs against merge-base adfdd33aeb = #8164, which is already on main and already contains:

  • the if (i === messages.length - 1) { … } trailing guard in cleanOrphanedToolCalls, and
  • the does not strip a trailing tool_use that has no subsequent message yet (unresolved, not orphaned) regression test, and
  • the reworked cleans orphaned tool_use blocks without matching tool_result fixture.

Commit 2ba990bde7 is still an ancestor of this branch, so nothing was lost — but its content is no longer part of this PR's reviewable delta. The net diff is the duplicate-tool_result dedup only (2 hunks in converter.ts, 3 new tests).

Concretely stale in the current description:

Claim Status
Title: "don't strip a trailing tool_use and …" first half is a no-op vs. base
Whole Trailing tool_use paragraph under Fix already on main via #8164
"One existing test unintentionally pinned the buggy trailing-orphan behavior … I gave it a real next message … added a new regression test for the trailing case" already on main
"New/updated unit tests in converter.test.ts (72 → 76 tests)" head is at 93 tests

Suggested title: fix(anthropic): dedup duplicate tool_result blocks sharing a tool_use_id.

Root cause is the chore(anthropic): merge main … commit (c03d5ac06b) — a rebase onto main instead of a merge would keep the reviewable diff aligned with the description. The branch is also 16 commits behind main.


2. Verification I ran

A/B harness driving the real AnthropicContentConverter.convertGeminiRequestToAnthropic under tsx, PR head vs. merge-base adfdd33aeb, asserting on the shape Anthropic rejects (>1 tool_result per tool_use_id in one message):

scenario base PR head
A — dup in one message [tool_result:dup="first", tool_result:dup="second"] ❌ 400 shape [tool_result:dup="first"]
B — dup + distinct id dup + other dup, other
C — dup across two consecutive user messages (merge site) dup dup
D — 3-way cross-message dup (untested in PR) dup dup
H — dup where 2nd block is the newer result dup dup ✅ (keeps "stale-placeholder", see §4)
J — cross-message dup, 2nd message also carries text dup dup + text preserved ✅

Both hunks are load-bearing — I re-ran with each reverted individually:

  • clean-site hunk only (merge-site dedup reverted) → C and D regress to the 400 shape.
  • merge-site hunk only (clean-site dedup reverted) → A, B, H, I regress to the 400 shape.

So all three new tests are genuinely efficacious; none is redundant with the other hunk.

Also:

  • converter.test.ts at head: 93 passed.
  • tsc --noEmit -p packages/core/tsconfig.json: clean (exit 0).
  • I checked the mirrored openaiContentGenerator/converter.ts for the same bug class (the Anthropic docstring says it mirrors that function). Both the same-message and cross-message duplicate cases already collapse to a single tool message per tool_call_id there — no follow-up needed on the OpenAI path.

Things the implementation gets right that are easy to get wrong:

  • At the clean site, validToolResultBlocks.has(...) is checked before seenToolResultIds.add(id), so an orphaned duplicate doesn't consume the slot and starve a later valid tool_result for the same id.
  • The merge-site dedup recomputes over the accumulated combined on every iteration, so 3+ consecutive user messages collapse correctly (verified in case D) rather than only pairwise.
  • if (!id) return true at both sites preserves prior behavior for id-less blocks.

3. The two dedup sites are copy-paste, not shared (minor)

The description says "Both sites now share the same first-wins dedup" — they don't literally share code; they're two independent inline filters that happen to agree today. Since this is exactly the kind of invariant that drifts when only one site gets a later fix, consider a tiny shared factory:

/** First-wins dedup of tool_result blocks by tool_use_id; id-less blocks always pass. */
function makeToolResultDeduper(): (id: string | undefined) => boolean {
  const seen = new Set<string>();
  return (id) => {
    if (!id) return true;
    if (seen.has(id)) return false;
    seen.add(id);
    return true;
  };
}

Both call sites then read keepToolResult(id), and the clean site keeps its extra validToolResultBlocks check in front.


4. first-wins keeps the older result — worth a one-line rationale (minor)

Verified: given [tool_result dup="stale-placeholder", tool_result dup="FINAL-real-output"], the head keeps stale-placeholder.

For the two causes the description names (a retried conversion pass; a history source that double-appends), the two blocks are byte-identical, so first-wins vs. last-wins is indistinguishable — this is not a bug today. But packages/core has several places that rebuild functionResponse payloads (compactionInputSlimming.ts:360, services/microcompaction/microcompact.ts:669, services/image-payload-references.ts:234); they all replace rather than append, and if one ever appends instead, first-wins would silently ship the pre-rewrite copy. A one-line comment stating why first-wins was chosen (observed duplicates are identical) would make the choice deliberate rather than incidental.


5. Test nits

  • (messages[2]?.content as Array<…>).filter(...) — the ?. guards messages[2], but .filter then runs on the cast result, so an index shift yields TypeError: Cannot read properties of undefined (reading 'filter') instead of a readable assertion failure. expect(messages).toHaveLength(3) first, or assert on messages.at(-1), would fail more legibly.
  • The cross-message test only asserts the tool_result blocks. Since the merge-site fix rebuilds lastMessage.content wholesale, it would be worth asserting the full merged content in at least one test — that non-tool_result siblings survive and still sort after the results.
  • Optional: the 3-way cross-message case (case D above) works but isn't covered; one more fixture would pin that the merge-site dedup is cumulative, not pairwise.

Summary: correctness, style, and test efficacy all look good — the dedup is minimal, correctly ordered, and each hunk is provably necessary. Please retitle and trim the description to the dedup-only scope (ideally rebasing onto main rather than merging), and the rest are optional polish.

@netbrah netbrah changed the title fix(anthropic): don't strip a trailing tool_use and dedup duplicate tool_result blocks fix(anthropic): dedup duplicate tool_result blocks sharing a tool_use_id Jul 31, 2026
… tests

Per wenshao's review on QwenLM#8163: factor the identical first-wins Set-based
tool_result dedup logic out of cleanOrphanedToolCalls and
mergeConsecutiveUserMessages into a single makeToolResultDeduper()
factory with the dedup rationale centralized in one doc comment.

Also strengthens the merge-site dedup tests: assert the full merged
content array (not just the filtered tool_result blocks) for the
two-message case, and add a three-message case pinning that dedup
accumulates across the whole combined array on every merge iteration,
not just pairwise between the two most recently merged messages.

@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.

Reviewed — no blockers. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

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

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification report — re-verified end-to-end at 2b51b893c

Re-ran the full verification against the current head after the refactor commit. Verdict: ✅ ready to merge.

Environment. Two fresh worktrees, npm ci in each, Node v24.18.1: PR head 2b51b893c vs. the merge-base adfdd33ae. main has 32 commits since that merge-base but none of them touch packages/core/src/core/anthropicContentGenerator/, so the merge-base A/B is representative of the merged result — no rebase surprise hiding in this diff.

# Check Result
1 New tests fail without the fix (A/B vs. merge-base) 4/4 fail on main, 94/94 pass on the PR
2 Each of the two hunks independently necessary Yes — disjoint 2 + 2 failure sets
3 Independent converter probe, 14 shapes 7 fixed, 7 byte-identical to main
4 Live Anthropic-protocol servers DeepSeek 400 → 200; Z.AI GLM 200 either way
5 Real qwen binary, real model, wire recorded Clean round trip, no duplicate ever emitted
6 tsc --noEmit / eslint / whole suite in the touched dir clean / clean / 219 passed

1 · The new tests are load-bearing

Same test file in both trees; only converter.ts differs. All four new assertions fail against the merge-base, so they genuinely pin the new behavior rather than restating it.

A/B unit tests

2 · Both hunks are independently load-bearing

Reverting exactly one hunk at a time on the PR head, with everything else untouched. The two failure sets are disjoint — neither call site is redundant with the other, which is the claim the description makes.

per-hunk necessity

3 · Independent probe of the real converter (not the PR's test file)

A standalone tsx script driving AnthropicContentConverter.convertGeminiRequestToAnthropic directly across 14 shapes, run in both worktrees and compared per case. 7 cases fixed, 7 byte-identical to main — including the ones that would have been easy to break: a normal round trip, a genuine orphan tool_use, partially-resolved parallel calls, and the same id legitimately reused in two separate turns.

converter probe

Two things worth recording from this run:

  • Case H (reused id in two separate turns) is byte-identical. The deduper is correctly scoped per message / per merge, not global — a global Set here would have silently eaten the second turn's result.
  • Case N shows the converter always emits tool_result blocks ahead of text blocks inside a user message. That means the seenNonToolResult ineligibility path can never strand a duplicate mid-message, so the ordering at the clean site (validity check before the deduper) is defensive rather than reachable — correct either way, just not currently exercisable.

4 · The decisive test: the two shapes on live servers

I POSTed the exact bytes each branch emits to two live Anthropic-protocol endpoints. No mocks, no local validator.

live API

  • api.deepseek.com/anthropicmain's output for both duplicate shapes is rejected:
    messages.2.content.1: each tool_use must have a single result.
    Found multiple `tool_result` blocks with id: dup_call
    
    The PR's output for the same history returns HTTP 200. This is the reported bug, reproduced against a real server and fixed. ✅
  • open.bigmodel.cn/api/anthropic (glm-5.2) — returns 200 for all shapes; its validator is lenient about this rule. Useful as a no-regression control: the PR's deduplicated shape is accepted there too, and the normal round trip is 200 on both branches everywhere.

So the user-visible impact is provider-dependent — which fits #8160 being reported at all rather than being caught by everyone.

5 · Reachability — real binary, real model, recorded wire

The qwen binary built from the PR head, run in an isolated HOME against a recording proxy that forwards verbatim to DeepSeek's Anthropic endpoint and audits every request body for duplicate tool_use_ids.

E2E wire capture

The session completed normally (correct answer from the file, correct directory listing), and the captured request shows a real parallel tool call — two distinct ids in one user message — passing through the dedup untouched. That is the regression I most wanted to see ruled out on real traffic, not just in fixtures.

6 · Every point from my previous review was addressed

Previous finding Status in 2b51b893c
Two copy-paste dedup sites that could drift Extracted into makeToolResultDeduper(), used by both
first-wins chosen without a stated rationale Documented in the new doc comment (observed duplicates are byte-identical)
(messages[2]?.content as …).filter(...) fails illegibly on an index shift expect(messages).toHaveLength(3) added first
Cross-message test only asserted the tool_result blocks Now asserts the full merged content
3-way cross-message case uncovered New test added; my probe case D confirms it independently

7 · Regression checks

Check Result
converter.test.ts on the PR head 94 passed
Whole anthropicContentGenerator/ suite 219 passed (3 files)
tsc --noEmit -p packages/core/tsconfig.json clean (exit 0)
eslint on both changed files clean
Normal round trip / genuine orphan / parallel partial / id reuse across turns byte-identical to main

No blockers. LGTM.

中文版本

维护者验证报告 —— 在 2b51b893c 上重新做了端到端验证

在 refactor 提交之后,针对当前 head 重新跑了一遍完整验证。结论:✅ 可以合并。

环境。 两个全新的 worktree,各自 npm ci,Node v24.18.1:PR head 2b51b893c 对比 merge-base adfdd33aemain 自 merge-base 以来有 32 个提交,但没有任何一个改动 packages/core/src/core/anthropicContentGenerator/,所以基于 merge-base 的 A/B 对比可以代表合并后的结果,不存在藏在 rebase 里的意外。

# 检查项 结果
1 没有这个修复时新测试是否会失败(对 merge-base 做 A/B) main 上 4/4 失败,PR 上 94/94 通过
2 两处改动是否各自都必要 是 —— 失败用例集合互不相交(2 + 2)
3 独立 converter 探针,14 种形状 7 种被修复,7 种与 main 逐字节一致
4 真实 Anthropic 协议服务器 DeepSeek 400 → 200;Z.AI GLM 两边都是 200
5 真实 qwen 二进制 + 真实模型,记录 wire 报文 一轮完整工具调用正常,从未发出重复块
6 tsc --noEmit / eslint / 改动目录的完整测试 干净 / 干净 / 219 通过

1 · 新测试确实是"有承重作用"的

两棵树里用的是同一份测试文件,只有 converter.ts 不同。四条新断言在 merge-base 上全部失败,说明它们真的锁定了新行为,而不是在复述既有行为。

A/B 单测

2 · 两处改动各自都不可省

在 PR head 上每次只回退其中一处,其余保持不变。两次实验失败的用例集合完全不相交——两个调用点谁也替代不了谁,这正是 PR 描述所主张的。

逐 hunk 必要性

3 · 对真实 converter 的独立探针(不是 PR 自带的测试文件)

一个独立的 tsx 脚本直接驱动 AnthropicContentConverter.convertGeminiRequestToAnthropic,覆盖 14 种形状,在两个 worktree 中分别运行并逐用例比对。7 种被修复,7 种与 main 逐字节一致——其中包括最容易被误伤的那些:正常的一轮工具往返、真正的孤立 tool_use、只解决了一半的并行工具调用,以及同一个 id 在两个独立轮次中被合法复用。

converter 探针

这次运行里有两点值得记录:

  • 用例 Hreused id 在两个独立轮次中复用)逐字节一致。去重器的作用域被正确地限制在"单条消息 / 单次合并"内,而不是全局——这里如果用一个全局 Set,第二轮的结果会被静默吃掉。
  • 用例 N 显示:在一条 user 消息内部,converter 总是把 tool_result 块排在 text 块之前。这意味着 seenNonToolResult 那条"不合格"路径永远不会把一个重复块卡在消息中间,所以 clean 处的顺序(先做合法性检查、再做去重)属于防御性写法而非当前可达路径——两种写法都对,只是目前触发不到。

4 · 决定性的一步:把两种形状打到真实服务器

我把两个分支各自产生的实际字节分别 POST 到了两个真实的 Anthropic 协议端点。没有 mock,也没有本地校验器。

真实 API

  • api.deepseek.com/anthropic —— main 针对两种重复形状产生的输出都被拒绝:
    messages.2.content.1: each tool_use must have a single result.
    Found multiple `tool_result` blocks with id: dup_call
    
    而 PR 针对同样历史产生的输出返回 HTTP 200。这就是被报告的那个 bug,在真实服务器上复现并被修复。✅
  • open.bigmodel.cn/api/anthropic(glm-5.2) —— 对所有形状都返回 200,它的校验器对这条规则比较宽松。这正好可以作为无回归的对照组:PR 去重之后的形状在这里同样被接受,而正常的一轮往返在两个分支、两个端点上都是 200。

所以这个问题对用户的可见影响是依赖 provider 的——这也解释了为什么 #8160 会被报上来,而不是所有人都撞到。

5 · 可达性 —— 真实二进制、真实模型、记录下来的 wire 报文

用 PR head 构建出的 qwen 二进制,在一个隔离的 HOME 下运行,指向一个记录型代理;该代理把请求原样转发给 DeepSeek 的 Anthropic 端点,并对每个请求体审计是否存在重复的 tool_use_id

端到端 wire 抓取

会话正常完成(从文件中读出了正确的值,也正确列出了目录),并且抓到的请求显示:一次真实的并行工具调用——同一条 user 消息里两个不同的 id——原封不动地通过了去重逻辑。这正是我最想在真实流量(而不仅仅是测试夹具)上排除掉的回归。

6 · 我上一轮 review 提的每一点都已处理

上一轮的意见 2b51b893c 中的状态
两处去重是复制粘贴,可能各自漂移 已抽成 makeToolResultDeduper(),两处共用
选择 first-wins 但没有说明理由 已在新的文档注释里说明(观察到的重复块是逐字节相同的)
(messages[2]?.content as …).filter(...) 在下标偏移时报错难读 已先加 expect(messages).toHaveLength(3)
跨消息用例只断言了 tool_result 现在断言了合并后的完整 content
三条消息的跨消息用例没有覆盖 已新增测试;我的探针用例 D 也独立确认了这一点

7 · 回归检查

检查项 结果
PR head 上的 converter.test.ts 94 通过
整个 anthropicContentGenerator/ 测试套件 219 通过(3 个文件)
tsc --noEmit -p packages/core/tsconfig.json 干净(exit 0)
对两个改动文件跑 eslint 干净
正常往返 / 真正的孤立调用 / 并行只解决一半 / 跨轮次 id 复用 main 逐字节一致

没有阻塞项。LGTM。

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 132 passed · 0 failed · 132 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:132 通过 · 0 失败 · 132 总计

Verification report (report.md)

# PR #8163 Deep Verification Report

**Verdict: `merge-ready`** — 132/132 scripted assertions passed, 0 failed.
Verified head: `2b51b893c4d86ab98adf094065bf7ed7e3040d67`

<details>
<summary>中文摘要</summary>

**结论**: `merge-ready`,132/132 脚本化断言全部通过。

**A/B 结论**: 对 5 个场景(同消息重复、重复+不同 id、跨两消息重复、跨三消息重复、无 id 透传)分别在 head 和 base 构建上运行。Head 13/13 通过(所有重复被去除,first-wins 保留,不同 id 不受影响);base 7/13 失败(重复全部存活)。两处修改(`cleanOrphanedToolCalls` 同消息去重、`mergeConsecutiveUserMessages` 跨消息去重)经独立回退验证,确认各自独立必要:回退 hunk 1 则同消息测试失败、跨消息通过;回退 hunk 2 则反之。

**Findings**: 无阻塞性问题。

**未覆盖范围**: 见下方 _Not covered_ 部分。

</details>

## Central Claim + A/B Table

**Central claim**: `cleanOrphanedToolCalls` and `mergeConsecutiveUserMessages` now deduplicate `tool_result` blocks sharing the same `tool_use_id`, preventing Anthropic HTTP 400 `"each tool_use must have a single result"`.

**Secondary claims**:
1. Dedup is first-wins (keeps first occurrence, drops later).
2. Both dedup sites (same-message in `cleanOrphanedToolCalls`, cross-message in `mergeConsecutiveUserMessages`) are independently load-bearing.

### A/B cells

Harness: `ab-harness.mjs` — imports compiled `converter.js` from each build, feeds 5 scenarios through `convertGeminiRequestToAnthropic`, asserts on output shape.

| Scenario | Oracle | Head | Base |
|---|---|---|---|
| 1. Same-message duplicate (`dup` ×2) | tool_result count = 1, content = "first" | ✅ 1 result, first-wins | ❌ 2 results survive |
| 2. Duplicate + distinct id | count = 2, `dup` ×1, `other` ×1 | ✅ correct | ❌ 3 results (`dup` ×2) |
| 3. Cross-message duplicate (2 user msgs) | merged msg has 1 tool_result, text survives | ✅ 1 result + text | ❌ 2 results survive |
| 4. Three-message duplicate | merged msg has 1 tool_result | ✅ 1 result | ❌ 3 results survive |
| 5. Normal single tool_result | count = 1 (no over-filter) | ✅ preserved | ✅ preserved |

**Head: 13/13 pass. Base: 6/13 pass, 7 fail** (all 7 are the dedup assertions — expected reds proving the change is load-bearing).

Evidence: `01-ab-head.png`, `02-ab-base.png`.

### Hunk independence matrix

Each hunk was reverted individually (scratch edit, vitest run, restore):

| Mutation | Same-message tests (1,2) | Cross-message tests (3,4) |
|---|---|---|
| Both reverted | ❌ 2 fail | ❌ 2 fail |
| Hunk 1 reverted only | ❌ 2 fail | ✅ 2 pass |
| Hunk 2 reverted only | ✅ 2 pass | ❌ 2 fail |
| Neither reverted (head) | ✅ 2 pass | ✅ 2 pass |

Both hunks are independently load-bearing — each protects a distinct test set the other cannot reach.

## Vacuity Check

All 4 new dedup tests fail against the fully-reverted source with behavioural mismatches (extra `tool_result` blocks in actual output vs expected deduplicated shape). Failure messages name expected-vs-actual values (e.g., `expect(messages[2]).toEqual(...)` showing the surviving duplicate `"content": "second", "tool_use_id": "dup"`). These are not import/compile failures.

## Targeted Gate

```
converter.test.ts: 94 tests passed (94), 0 failed
Duration: 4.04s
```

Evidence: `04-test-gate.png`.

## Findings

None.

## Not Covered

- **Per-commit attribution**: the checkout is depth 2 (shallow); only the merge commit, base tip, and PR head are locally reachable. The metadata lists 4 commits but `git rev-list HEAD^1..HEAD^2` returns only 1. Per-commit verification was out of reach; the aggregate `HEAD^1..HEAD` diff was verified.
- **Live Anthropic API verification**: the PR description claims live 400→200 verification against a real Anthropic endpoint. This was not re-tested (no API credentials in this environment); the A/B harness verifies the converter output shape that would produce the 400.
- **Full `anthropicContentGenerator/` suite**: only `converter.test.ts` was run (94 tests). The broader suite (193 tests per PR description) was not re-run.
- **Repo-wide gates** (lint, typecheck, full test suite): not run; the PR's own CI covers these.
- **`id`-less `tool_result` blocks in duplicate scenarios**: the harness tests a single id-less block passthrough but does not test multiple id-less blocks (the code passes them through unfiltered by design, matching prior behavior).
- **Edge cases**: `tool_use_id` values that are empty strings (falsy but not `undefined`) — the code treats `!id` as pass-through, so `""` would bypass dedup. This matches the pre-existing behavior for id-less blocks and is not a regression.

## Methodology

Environment: `node:22-bookworm` container, CI verify job. Head build: pre-built `packages/core/dist/` at merge commit `ad0eab5`. Base build: `git worktree add tmp/base-tree HEAD^1`, symlinked root + package `node_modules`, compiled via `npx tsc -p packages/core/tsconfig.json`. Base converter confirmed to lack `makeToolResultDeduper` (grep count: 0 vs head's 3). A/B harness (`ab-harness.mjs`) imports compiled `converter.js` via dynamic `import()` and drives `convertGeminiRequestToAnthropic` with 5 scenarios. Vacuity and hunk-independence checks used scratch edits to `converter.ts` (reverted and restored after each run). Raw logs: per-cell stdout in the evidence captures. Base worktree removed after A/B cells were captured.

Evidence images

01-ab-head

02-ab-base

04-test-gate

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@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. ✅

@wenshao
wenshao added this pull request to the merge queue Aug 1, 2026
Merged via the queue into QwenLM:main with commit 40efe41 Aug 1, 2026
61 checks passed
pull Bot pushed a commit to bit-cook/qwen-code that referenced this pull request Aug 1, 2026
…_use is orphaned (QwenLM#8166)

* fix(anthropic): don't strip a trailing tool_use with no subsequent message

Fixes QwenLM#8159

cleanOrphanedToolCalls treated any assistant tool_use with no matching
tool_result as an orphan and stripped it -- including a tool_use in the
very last message, where there is no subsequent message to have found a
result in yet. "No result yet" is not the same as "no result ever": the
tool may simply not have finished executing, or the conversion may be
happening for a reason other than sending the completed turn to
Anthropic (token counting, a resumed/replayed session snapshot, a retry
issued before tool execution completes, ...).

Silently deleting a currently-active tool_use corrupts the assistant's
most recent turn, and the damage compounds when that turn also carries a
signed extended-thinking block: the block's signature is computed over
the full sibling content of the turn, so removing the tool_use next to
it invalidates the signature and replaying the mutated turn produces
Anthropic 400 "thinking blocks in the latest assistant message cannot be
modified" -- a genuinely confusing error for something the client did to
its own outgoing request.

Fix: when an assistant tool_use is in the last message of the array (no
message follows it at all), treat its tool_use blocks as valid/unresolved
rather than scanning for a match that can't exist yet. A tool_use is only
condemned as orphaned when a subsequent message was actually scanned and
found to lack a matching tool_result.

Also fixed one existing test that unintentionally pinned the buggy
behavior as expected output ("cleans orphaned tool_use blocks without
matching tool_result" used a fixture with no subsequent message at all,
which is the trailing case, not a genuine orphan) -- added a real next
message with unrelated content so it now exercises an actual orphan.
Added a new regression test for the trailing case.

Verification:
- New/updated unit tests in converter.test.ts (73 tests, was 72).
- Full anthropicContentGenerator/ suite: 193 tests pass.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.

* fix(anthropic): prune stale thinking signatures after a sibling tool_use is removed

Fixes QwenLM#8162

Anthropic validates a thinking/redacted_thinking block's opaque
signature against the content it was originally computed over.
Removing a sibling tool_use from that same turn -- whether in this
request's own cleanup pass or in an earlier compaction cycle now baked
into stored history -- can leave a thinking block whose signature no
longer matches, producing:

  "thinking blocks in the latest assistant message cannot be modified"

We independently hit this exact error text this session from a related
cause (a different orphan-cleanup implementation stripping the *current*
turn's tool_use and leaving its thinking sibling stale -- see QwenLM#8159/
QwenLM#8163), which is what prompted auditing this converter for the same
class of gap.

Two patches, ported from a downstream fork's previously-tested fix
(closed a "residual class of 400 ... errors on Vertex-routed
claude-opus-4.x sessions with adaptive thinking" per that fix's own
commit message):

PATCH-A (same-turn cascade, in cleanOrphanedToolCalls): when a tool_use
is stripped from an assistant turn by this same cleanup pass, its
thinking/redacted_thinking siblings in that turn are now cascade-removed
too, since their signature was computed over content that included the
now-gone tool_use.

PATCH-B (cross-turn, new pruneUntrustworthyThinking pass): catches the
case PATCH-A can't -- a non-latest assistant turn whose thinking
survived earlier trims but whose tool_use was already gone by the time
it entered this request's history. Only ever touches turns that are NOT
the most recent assistant turn (Anthropic's contract requires the latest
turn's signatures to replay byte-exact regardless). A strictly
thinking-only older turn (no surviving tool_use, no other text) has its
thinking downgraded to plain text so the model still sees the historical
reasoning without an unreplayable signature; redacted_thinking has no
plaintext fallback and is dropped instead. A turn that already carries
real text alongside the untrustworthy thinking is left as-is (narrower
than a blanket rewrite, matching the tested downstream fix).

pruneUntrustworthyThinking is skipped when injectThinkingOnToolUseTurns
is set (DeepSeek compatibility path): DeepSeek requires a synthetic
empty thinking placeholder structurally on every tool-use turn and
doesn't validate a signature the way Anthropic does, so this pass'
"empty/untrustworthy thinking" concept doesn't apply there and would
strip the very placeholder DeepSeek needs.

IMPORTANT calibration note on live verification: I fully live-verified
PATCH-A's mechanism this session via a related bug (QwenLM#8159/QwenLM#8163) and via
code-reading of this exact cascade. For PATCH-B (the cross-turn case), I
attempted a live reproduction against the real Anthropic Messages API
(via our corporate proxy, Vertex-routed claude-sonnet-4-6, extended
thinking enabled): obtained a genuine signed thinking+tool_use turn,
then replayed it as a non-latest turn with the tool_use stripped but
thinking intact, followed by a new user turn. This did NOT reproduce a
400 -- the API returned 200, with the model noticing the missing tool
call itself and self-correcting in its response text rather than the
server rejecting the malformed signature context. So the cross-turn
mechanism, while structurally sound and matching an already-tested
downstream fix's own historical diagnosis, is not independently
live-confirmed via this proxy path in my environment. PATCH-B is still
included as a defensive, non-regressive improvement (it can only ever
remove content that's already been flagged as untrustworthy, never add
risk), but I want reviewers to weigh this transparently rather than
overclaim a live 400 I couldn't reproduce.

Verification:
- New tests: same-turn cascade (thinking dropped alongside its orphaned
  tool_use sibling), cross-turn thinking-only downgrade, cross-turn
  redacted_thinking-derived drop, latest-turn exemption, and
  narrower-scope-preserved (real text alongside stale thinking left
  untouched).
- Full anthropicContentGenerator/ suite: 198 tests pass (was 193; net +5
  tests). One pre-existing DeepSeek test required the
  injectThinkingOnToolUseTurns gate described above to keep passing.
- tsc --noEmit -p packages/core/tsconfig.json and eslint clean for
  touched files.
- Live proxy verification for PATCH-A's mechanism per above; PATCH-B's
  cross-turn case did not reproduce a 400 in my environment (see note
  above and the corresponding comment on QwenLM#8162).

* docs(anthropic): document pruneUntrustworthyThinking's false-positive heuristic

Per review feedback on PR QwenLM#8166: the function cannot distinguish 'this
turn's tool_use was removed by an earlier trim' from 'this turn was
always thinking-only' -- both are structurally identical by the time
this pass runs (no surviving tool_use, no other text). Make that
imprecision explicit in the doc comment rather than implying this only
touches genuinely-stale turns, and note the live-verification gap
(the specific 400 this guards against did not reproduce against a
Vertex-routed proxy).

* refactor(anthropic): narrow to the same-turn cascade, drop the cross-turn heuristic

Per wenshao's live-verified review on QwenLM#8166: split PATCH-A and PATCH-B,
keep PATCH-A plus the one genuine fix inside PATCH-B, drop the rest.

Kept and refined (PATCH-A, cleanOrphanedToolCalls's same-turn cascade):
scoped the thinking/redacted_thinking cascade to only fire when NO
tool_use survives the same turn, not merely "any tool_use removed" (S2).
A turn shaped [thinking, tool_use A, tool_use B] where only B is a
genuine orphan now keeps both the surviving tool_use A and its thinking
sibling -- previously the thinking was stripped even though A still
needed it to satisfy Anthropic's manual-mode "final turn must begin
with thinking when a tool_use is present" rule, trading one 400 for
another.

Kept (S6, extracted into its own function, dropEmptyTextThinkingBlocks):
an unconditional guard dropping any thinking block with empty text on a
non-latest assistant turn. This is unconditionally correct regardless
of tool_use presence and isn't redundant with the existing
dropUnsignedThinkingFromAssistantMessages pass, which is itself gated
to non-native-baseURL + adaptive-thinking + non-DeepSeek configs -- this
guard also covers native Anthropic API sessions that pass never touches.

Must run AFTER dropUnsignedThinkingFromAssistantMessages, not before:
that pass has a deliberate fail-loud design -- a thinking block with a
missing/empty signature on a turn inside the still-active tool-use
chain throws rather than silently drops, since Claude requires all of
an active loop's thinking blocks to be passed back complete and
unmodified. An empty-text, unsigned redacted_thinking-derived block is
unsigned by that same definition; if dropEmptyTextThinkingBlocks ran
first it deleted the block before the fail-loud check ever saw it,
silently swallowing exactly the proxy bug that throw exists to surface.
This is the identical pass-ordering hazard the removed PATCH-B
heuristic was rejected for, reintroduced by this refactor's own
extraction -- caught in review and fixed here by reordering, with a
regression test.

Dropped entirely (the broader pruneUntrustworthyThinking heuristic):
"a non-latest, thinking-only turn with no surviving tool_use is
structurally untrustworthy, downgrade its thinking to text." Removed
because:
- Pass ordering: it ran before dropUnsignedThinkingFromAssistantMessages
  for the same reason described above -- re-typing untrustworthy
  thinking to plain text first made the drop pass no longer recognize
  it as thinking at all, so genuinely unsigned reasoning that should
  have been removed was instead sent to the model as assistant text.
- Its DeepSeek exclusion only covered thinking-on mode
  (!injectThinkingOnToolUseTurns); DeepSeek-with-thinking-off went
  through a different, deliberately-permissive strip pass that this
  heuristic wasn't gated against, rewriting a shape that pass
  intentionally leaves alone.
- Live A/B verification against a real session showed a thinking-only
  turn (that never carried a tool_use, so its signature was never
  actually invalidated) getting re-typed to text one request later,
  purely because a newer assistant turn had displaced it as "latest" --
  changing the serialized prefix between two consecutive requests
  (invalidating a cache breakpoint) and costing extra tokens on
  last-turn-only models that would otherwise strip prior-turn thinking
  for free.
- The state this heuristic exists to clean up -- a non-latest turn whose
  tool_use went stale in an earlier trim -- did not reproduce against
  this codebase's actual machinery: chatCompressionService compresses
  the full history rather than partially trimming it (confirmed the
  post-compaction attachment path preserves a trailing functionCall
  turn's thinking sibling intact rather than splitting it), and
  repairOrphanedToolUseTurns already synthesizes an error tool_result
  for a genuine cross-turn orphan on every send, upstream of the
  converter, before this pass would ever see it. truncateHistory and
  stripThoughtsFromHistory were also checked and don't produce the
  target state either.

Also fixes two bot review Suggestions on the current diff: a duplicated
JSDoc paragraph in cleanOrphanedToolCalls's doc comment, and missing
test coverage for the branch where a cascade empties a turn out entirely
(the message must be dropped and its surrounding user turns merged).

---------

Co-authored-by: Palanisamy, Dinesh <Dinesh.Palanisamy@netapp.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.3.

wenshao added a commit that referenced this pull request Aug 2, 2026
…nt parses

Requested after I argued against it, and the argument was thin. I read
the corpus — one Anthropic mock in 94 — and treated that as demand. The
product says otherwise: `anthropicContentGenerator/` is 11,618 lines and
three PRs touched it in the last three months, #8163 and #8166 among
them. One of those was settled by posting the two branches' actual bytes
to a real Anthropic endpoint and reading back 400 versus 200. "Only one
mock exists" does not mean nobody needed one; it means faking it by hand
was harder than finding a real endpoint, which is the case FOR shipping
it rather than against.

The shapes are taken from this repo's own generator, not from memory:
the six SSE events it parses, `input_json_delta` for streamed tool
arguments, the four usage fields including the cache counters, and the
block-array content. A mock whose protocol came from recollection would
be testing the recollection.

Three differences that a from-memory version gets wrong:

`/v1/messages` ends on `message_stop` and sends no `[DONE]` — that is
OpenAI's terminator, and a client waiting for it hangs, which is the same
failure this command just fixed on the responder side.

Tool arguments stream as `partial_json`, not as a finished `input`
object; a client that accumulates them and one that expects them whole
behave differently.

The system prompt is a top-level `system`, not a message — and "the
system prompt says verifier" is how 20% of the corpus classified its
requests, so a responder branching on prompt text would never see it.

The wire is recorded per request as well as passed to the responder: an
A/B whose two sides dialled different endpoints is not comparing the same
thing, and the log is where that shows.

Two of the four guarantees were pinned by assertions that did not pin
them — `toContain('input_json_delta')` survives handing the arguments over
whole, and testing `anthropicText` as a pure function survives the
request path never calling it. Both are parsed and driven now, and all
four fail a mutation.
pull Bot pushed a commit to Stars1233/qwen-code that referenced this pull request Aug 2, 2026
…s yours (QwenLM#8355)

* feat(review): mock-provider — the protocol is a fixture, the answer is yours

The single most-rewritten artifact in this repo's verification corpus:
94 hand-written mock servers across the maintainer sessions, median
3.3 KB each. What they share is the protocol; what they differ on is the
reply.

  93%  SSE framing          92%  a `[DONE]` terminator
  73%  /v1/chat/completions 69%  a usage block
  62%  a request log

Below that the agreement stops, and the split is not "how much mock to
ship" but which half. 43% emit `tool_calls` — and across those the SSE
SKELETON is unanimous (41 of 41 carry the chunk `index`) while the
CONTENT is not: thirteen distinct tool names, four appearing exactly
once, the most common at 29%. Request classification is 20%, scenario
switching 14%. So this command owns the framing and the caller's
responder owns the answer, which is the same line `build-test` and
`test-delta` already draw.

Two things it takes over that the corpus did by hand:

The port. 67 of 94 read one from an env var — a number someone chose and
hoped was free, so the second review on a machine collides and its
failure looks like the product's. This listens on 0 and reports what the
OS gave it.

The record. A request log is where an A/B gets its evidence: the same
drive against two trees, then a diff of the two request sequences. That
only works if both sides write the same shape, so it is JSONL here
rather than whatever a given session's mock happened to print.

One correction worth recording: an earlier pass over this corpus claimed
68% did error injection and that a `context_window` rule was a common
hardening. Both came from loose patterns. Measured properly, injection is
about 17% and the context rule appears in **0 of 94** — it was one
session's good idea, not a practice. Neither shaped this command.

* fix(review): bound the request, and number only what the responder saw

Round-1 findings on this PR, both measured before fixing.

`/v1/models` is answered without consulting the responder, but it still
took a request number: two calls with a models fetch between them handed
the responder `[1, 3]`. The field's own doc says "a responder that
answers the Nth call needs this", and a responder keyed on its third call
would have fired on its second.

Nothing bounded a request body. Measured: a 40 MiB POST was read whole
into memory, handed to the responder, and written to the JSONL twice —
once as the request and once inside the reply record — leaving an 80 MiB
log. `drive` had just grown an 8 MiB log cap for this exact hazard, and
this was a larger door beside it; `build-test`'s disk floors exist
because a review filled its own machine once already.

Over the ceiling is 413, not a trim. A truncated body parses to different
JSON, so the mock would answer a request the client never sent — a
behaviour difference introduced by the harness, which is the one thing it
must never introduce. What the RECORD keeps is trimmed instead, and says
where: an A/B needs the shape of the request sequence, not every byte of
every prompt. Same measurement after: 28 KiB.

* docs(review): say which protocol this fakes, and which tool covers the rest

The command read as more general than it is. It fakes ONE protocol — the
OpenAI-compatible chat API — and nothing in it is qwen-specific, so any
project whose product calls such an endpoint can drive against it. But
the protocol is also the boundary, and neither the module header nor
`--help` said so.

Of the 94 mocks measured: 73% spoke this protocol, 23% stood up the
project's own HTTP service, and single cases faked MCP/JSON-RPC, OAuth
and the Anthropic API. For those, the tool is `drive` — readiness,
completion and cleanup for any process a reviewer starts.

That is the same division the rest of this line draws, and worth stating
where a reader meets it: a protocol 73 files agree on is a fixture, a
service that differs across all 22 is a judgement. Said in the header,
in `--help`, and in the PR body in both languages.

* fix(review): a malformed reply from the responder HUNG the request

Round-3 finding, and the worst shape the failure could take.

The responder is the caller's module, and a caller's module is exactly
the kind of thing that returns `undefined` from a branch nobody took.
Measured, one probe per shape: `undefined`, `null`, a bare string, a
`status` that is not a number, and tool args holding a circular
reference each left the request with **no response at all**. Not an
error — a hang.

That matters more than a wrong answer would. The product under test sits
waiting, `drive` eventually reports `timed-out`, and a bug in the
harness has been presented as the behaviour of the diff. This whole line
of work exists to stop the harness manufacturing findings, and here it
was manufacturing the loudest kind.

Two quieter shapes went with it: `{}` and `{foo: 1}` answered 200 with
an empty completion, indistinguishable from a model that legitimately
said nothing, and `status: 999` was sent on the wire as an HTTP status.

Every reply is now checked before it is used, and a bad one is a 500
naming what the responder returned and what it should have. Nine
malformed shapes, nine immediate 500s; the two valid shapes are
untouched. Pinned by comparing the whole result table, so a regression
names which shape broke rather than just failing a count.

* feat(review): speak Anthropic too, in the shapes this repo's own client parses

Requested after I argued against it, and the argument was thin. I read
the corpus — one Anthropic mock in 94 — and treated that as demand. The
product says otherwise: `anthropicContentGenerator/` is 11,618 lines and
three PRs touched it in the last three months, QwenLM#8163 and QwenLM#8166 among
them. One of those was settled by posting the two branches' actual bytes
to a real Anthropic endpoint and reading back 400 versus 200. "Only one
mock exists" does not mean nobody needed one; it means faking it by hand
was harder than finding a real endpoint, which is the case FOR shipping
it rather than against.

The shapes are taken from this repo's own generator, not from memory:
the six SSE events it parses, `input_json_delta` for streamed tool
arguments, the four usage fields including the cache counters, and the
block-array content. A mock whose protocol came from recollection would
be testing the recollection.

Three differences that a from-memory version gets wrong:

`/v1/messages` ends on `message_stop` and sends no `[DONE]` — that is
OpenAI's terminator, and a client waiting for it hangs, which is the same
failure this command just fixed on the responder side.

Tool arguments stream as `partial_json`, not as a finished `input`
object; a client that accumulates them and one that expects them whole
behave differently.

The system prompt is a top-level `system`, not a message — and "the
system prompt says verifier" is how 20% of the corpus classified its
requests, so a responder branching on prompt text would never see it.

The wire is recorded per request as well as passed to the responder: an
A/B whose two sides dialled different endpoints is not comparing the same
thing, and the log is where that shows.

Two of the four guarantees were pinned by assertions that did not pin
them — `toContain('input_json_delta')` survives handing the arguments over
whole, and testing `anthropicText` as a pure function survives the
request path never calling it. Both are parsed and driven now, and all
four fail a mutation.

* fix(review): stop answering requests this mock has no business answering

Round-4 findings, both measured.

A call to `/v1/embeddings`, a GET to the chat endpoint, a non-JSON body
and an entirely empty body each came back as a plausible 200 completion.
So a product that dialled the wrong endpoint, used the wrong method, or
sent a broken payload looked, from the review's side, like it was
working. That is the mock concealing the exact defect the review exists
to find — worse than any wrong answer it could give. Each is a 400 now,
saying which of the three it was, and the refusal goes in the record so a
run that dialled wrong can be seen afterwards.

The log bound from round 1 only held half. `text` was trimmed while
`...mreq` spread the parsed `body` into the same entry carrying the
identical payload: a 200 KB system prompt gave an 8 KB `text` and a
205 KB log. The record now summarises the body by its keys rather than
copying it — the evidence an A/B needs is the request's shape, not every
byte of every prompt. Same measurement after: 9.2 KiB.

Four probes in this round, four combinations that were never exercised:
Anthropic non-stream tool use, empty text on both wires, and the four
malformed requests above. The first three were already correct.

* fix(review): a number is spent only when the responder is actually asked

Round-5 finding, and the second pass over something round 1 thought it
had closed. Round 1 stopped `/v1/models` from taking a request number.
Running all four rounds' fixes together on one mixed sequence showed the
other two ways it still could — the record read `[1, 2, 2, 3, 4, 5]`:

  the models call REUSED the previous request's number, having none of
  its own to report;

  a refused `/v1/embeddings` incremented past it, because the refusal
  added in round 4 lands after the counter.

A responder keyed on its Nth call reads a sequence like that and fires on
the wrong request — which is exactly the failure round 1 set out to fix,
surviving in two shapes it had not looked at.

The counter now moves only for a request that will reach the responder,
and the record carries `null` for everything else rather than a number
that would read as "the responder handled this". `Responder` takes a
`RespondedRequest` where `n` is non-null, so the invariant is in the type
rather than in a comment.

Measured after, same sequence: the responder sees `[1, 2, 3]` and the
record reads `[1, null, 2, null, null, 3]`.

* test(review): pin the invariants across a mixed sequence, not one path at a time

Round-6, and a response to what round 5 taught rather than a new defect:
every fix so far was right on its own, and the counter was wrong across
them. A single-path assertion cannot see that — it exercises one route
and stops.

So this drives 120 requests over every known path, in a deterministic
pseudo-random order, and checks what must hold whatever the order is:
one record per request; the responder asked exactly for the requests
that reach it, numbered 1..N with no gaps; the record's numbering
agreeing with the responder's; no entry carrying the parsed body however
large the request; every entry naming a valid wire.

It earns its place by catching what the single-path tests did not.
Restoring each of three historical bugs — round 5's `!isModels`
increment, round 4's missing refusal, round 1's copied body — turns this
one test red on its own.

Nothing was broken this round: all seven invariants held before it was
written. It is here so the next stack of fixes cannot quietly break each
other the way round 5's did.

* fix(review): four findings from a /review run on this PR

All four hold, and each names something six rounds of my own review had
not looked at.

R1-4 is the one that matters. `replyProblem` guarded the tool branch's
`args` against a circular reference and left the status branch's `body`
unguarded — so `{status: 500, body: circularObj}` passed validation, and
`record()`'s `JSON.stringify` then threw "Converting circular structure
to JSON" as an unhandled rejection: the request hung and the drive around
it timed out. Confirmed by probe before fixing. Whether a reply can be
serialised is a property of the reply, not of the branch it arrived on —
the same one-directional reasoning round 5 caught in the counter.

R1-1: the `describe` still said "OPENAI-COMPATIBLE endpoint (that
protocol only)" after `/v1/messages` was added. A user reading `--help`
concludes the command cannot serve an Anthropic-wire product and
hand-writes a second mock. Pinned against the ROUTES the implementation
serves rather than against a sentence, so a third one added without
saying so fails the test instead of misleading someone quietly.

R1-2 and R1-3: the `--responder` module loader and the CLI handler had
zero coverage — every test passed `respondOverride` and called
`startMockProvider` directly, so neither branch had ever executed. The
review found the `?? mod.default` fallback survives deletion with every
test green; a caller writing `export default function respond` would be
told their module exports no `respond` function. Both are driven now,
through fixtures in the repo rather than a temp file, because vitest
cannot import a module from outside the project root — which is why
these paths went untested in the first place.

The handler test asserts the TTL in seconds by elapsed time: dropping
the `* 1000` turns a 600-second TTL into 0.6, and the mock would exit
before the product connects, which the drive would report as a product
that never answered.
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_use.id / tool_result.tool_use_id not sanitized to Anthropic's required charset

4 participants