Skip to content

fix(anthropic): cascade-strip stale thinking siblings when their tool_use is orphaned - #8166

Merged
wenshao merged 6 commits into
QwenLM:mainfrom
netbrah:fix/anthropic-stale-thinking-signatures
Aug 1, 2026
Merged

fix(anthropic): cascade-strip stale thinking siblings when their tool_use is orphaned#8166
wenshao merged 6 commits into
QwenLM:mainfrom
netbrah:fix/anthropic-stale-thinking-signatures

Conversation

@netbrah

@netbrah netbrah commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Cascade-strips a thinking/redacted_thinking sibling from an assistant turn when the last surviving tool_use is stripped from that same turn as an orphan (in cleanOrphanedToolCalls), plus a small unconditional guard dropping any thinking block with literally empty text on a non-latest assistant turn.

This PR previously also included a broader cross-turn heuristic (pruneUntrustworthyThinking) attempting to catch a non-latest, thinking-only turn whose tool_use had gone stale via an earlier history trim. That heuristic has been removed after @wenshao's live-verified review — see "What changed since the original submission" below.

Why it's needed

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 turn — via this converter's own orphan-cleanup pass — leaves 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 #8159/#8163), which is what prompted auditing this converter for the same class of gap.

What changed since the original submission

@wenshao built both worktrees locally, ran an A/B against the merge-base, drove a live session against a real Anthropic-protocol endpoint, and tested this codebase's actual compaction and orphan-repair machinery directly rather than reasoning about it abstractly. Findings:

  1. The same-turn cascade (kept) had a gap: a turn shaped [thinking, tool_use A, tool_use B] where only B is a genuine orphan still cascaded away the thinking even though A survives and still needs it — per Anthropic's manual-mode contract, the final turn must begin with a thinking block when any tool_use is present, so stripping it here traded one 400 for another. Fixed: the cascade now only fires when no tool_use survives the turn.
  2. One genuine, narrow fix was bundled inside the broader heuristic: an unconditional guard dropping any thinking block with literally empty text (arising when a redacted_thinking block round-trips through Gemini-Part conversion and loses its opaque data). This is correct regardless of the broader heuristic's premise. Kept, extracted into its own function (dropEmptyTextThinkingBlocks).
  3. The broader cross-turn heuristic itself had real problems: a pass-ordering bug (it ran before dropUnsignedThinkingFromAssistantMessages, which exists specifically to fail loudly when a proxy omits a signature mid-active-tool-loop — re-typing the block to text first made that check no longer recognize it as thinking, silently swallowing exactly the proxy bug the check exists to surface); an incomplete DeepSeek exclusion (only gated one of DeepSeek's two thinking modes); and a live-verified false positive (a thinking-only turn that never carried a tool_use — so its signature was never actually invalidated — got re-typed to text one request later purely because a newer assistant turn displaced it as "latest," breaking a cache breakpoint and costing tokens on last-turn-only models). wenshao also tried to reproduce the state the heuristic exists to clean up (a non-latest turn whose tool_use went stale via an earlier trim) against this codebase's real compaction and orphan-repair paths and could not. Removed entirely — this repo's own convention is not to carry defensive code for a state that can't currently occur.

During implementation, the same pass-ordering hazard identified in point 3 above turned out to still apply to the extracted guard from point 2 (an empty-text, unsigned redacted_thinking-derived block on an active tool-loop turn could be silently deleted before the fail-loud check saw it) — caught in a follow-up review round and fixed by running the guard after that check, with a regression test.

Reviewer Test Plan

How to verify

cd packages/core && npx vitest run src/core/anthropicContentGenerator/converter.test.ts

Expected: all 97 tests pass. Key scenarios: the same-turn cascade fires only when no tool_use survives the turn; an empty-text thinking block is dropped on non-latest turns but the latest turn is always exempt; the active-tool-loop fail-fast still throws when an empty-text unsigned thinking block sits on a non-latest step of an in-progress tool loop; a cascade that empties a turn out entirely drops the message and merges the surrounding user turns.

Evidence (Before & After)

N/A — no UI surface; behavior change is in which thinking/redacted_thinking blocks survive conversion to the outbound Anthropic request body. See the test names for exact before/after shapes per scenario.

Tested on

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

Risk & Scope

  • Narrower than the original submission: only removes content that would otherwise cause a confirmed 400 (the same-turn cascade) or is unconditionally invalid regardless of context (empty-text thinking). No heuristic-based rewriting of turns whose validity can't be determined with certainty.
  • Breaking changes / migration notes: none to any public API.

Linked Issues

Fixes #8162

中文说明

本 PR 做了什么

当一个 assistant turn 中最后一个幸存的 tool_use 作为孤儿被剥离时(在 cleanOrphanedToolCalls 中),级联移除该 turn 中的 thinking/redacted_thinking 兄弟块;此外还有一个很小的、无条件的保护逻辑,用于丢弃非最新 assistant turn 上任何文本为空的 thinking 块。

本 PR 此前还包含一个更宽泛的跨 turn 启发式(pruneUntrustworthyThinking),试图捕获一个非最新、纯 thinking 的 turn,其 tool_use 可能在更早的历史裁剪中已经过期。该启发式已被移除——详见下方"自最初提交以来的变化"。

为什么需要这个改动

Anthropic 会将 thinking/redacted_thinking 块的不透明签名与其最初计算时所依据的内容进行校验。通过本转换器自身的孤儿清理过程移除该 turn 的一个兄弟 tool_use,会留下一个签名不再匹配的 thinking 块,导致:thinking blocks in the latest assistant message cannot be modified

本次 session 中我们从一个相关但不同的成因独立撞上了完全相同的报错文本(另一种孤儿清理实现剥离了当前 turn 的 tool_use,却留下了过期的 thinking 兄弟块——见 #8159/#8163),这正是促使我们审查此转换器是否存在同类缺口的原因。

自最初提交以来的变化

@wenshao 在本地构建了两个 worktree,对 merge-base 做了 A/B 测试,针对真实的 Anthropic 协议端点跑了一次 live 会话,并直接对这个代码库真实的压缩与孤儿修复机制做了测试,而不是停留在抽象推理层面。发现如下:

  1. 同 turn 级联(已保留)存在一个缺口:形如 [thinking, tool_use A, tool_use B] 的 turn,如果只有 B 是真正的孤儿,此前仍会把 thinking 一并级联移除,即便 A 幸存下来仍然需要它——按照 Anthropic 手动模式的契约,只要 turn 中还存在 tool_use,最终 turn 就必须以 thinking 块开头,因此在这里剥离 thinking 相当于用一个 400 换了另一个 400。已修复:现在只有当该 turn 中没有任何 tool_use 幸存时,级联才会触发。
  2. 更宽泛的启发式中捆绑了一个真正有效、范围很窄的修复:一个无条件的保护逻辑,会丢弃任何文本字面为空的 thinking 块(这种情况出现在 redacted_thinking 块经过 Gemini Part 转换往返、丢失其不透明 data 之后)。无论更宽泛启发式的前提是否成立,这一点本身都是正确的。已保留,并被提取为独立函数(dropEmptyTextThinkingBlocks)。
  3. 更宽泛的跨 turn 启发式本身存在真正的问题:一个执行顺序上的 bug(它在 dropUnsignedThinkingFromAssistantMessages 之前运行,而后者正是专门用来在代理漏传签名、且该 turn 仍处于一个正在进行中的工具调用循环时主动报错的——先把该块重新标记为文本,会让后面这个检查再也无法识别出它是 thinking,从而悄悄掩盖了这个检查本应暴露出的代理问题);一个不完整的 DeepSeek 排除逻辑(只覆盖了 DeepSeek 两种 thinking 模式中的一种);以及一个经 live 验证的假阳性(一个从未携带过 tool_use、因而签名从未真正失效的纯 thinking turn,仅仅因为被一个更新的 assistant turn 取代了"最新"的位置,就在下一次请求中被重新标记为文本——这会破坏缓存断点,并且在只信任最后一轮的模型上白白消耗 token)。wenshao 还尝试针对这个代码库真实的压缩与孤儿修复路径去复现该启发式本应清理的状态(一个 tool_use 在更早的裁剪中已经过期的非最新 turn),但未能复现。已完全移除——为一个当前架构下根本不会出现的状态编写防御性代码,不符合本仓库自身的约定。

在实现过程中,第 3 点中提到的同一种执行顺序隐患,被发现同样适用于第 2 点中提取出来的保护逻辑(一个处于活跃工具调用循环中的非最新步骤上、文本为空且未签名的 redacted_thinking 衍生块,可能在快速失败检查看到它之前就被悄悄删除)——这一点在后续的一轮审查中被发现,并通过把该保护逻辑调整到那个检查之后运行来修复,同时补充了对应的回归测试。

审阅者测试计划

如何验证

cd packages/core && npx vitest run src/core/anthropicContentGenerator/converter.test.ts

预期:全部 97 个测试通过。关键场景包括:只有当该 turn 中没有任何 tool_use 幸存时,同 turn 级联才会触发;文本为空的 thinking 块会在非最新 turn 上被丢弃,但最新 turn 始终被豁免;当一个文本为空、未签名的 thinking 块出现在一个正在进行中的工具调用循环的非最新步骤上时,主动失败检查仍然会正确报错;当级联把某个 turn 完全清空时,该消息会被丢弃,并且前后相邻的 user turn 会被合并。

证据(前后对比)

不适用——没有 UI 界面;行为变化体现在转换到出站 Anthropic 请求体时哪些 thinking/redacted_thinking 块得以存活。各场景具体的前后形态见测试名称。

测试环境

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

风险与范围

  • 相比最初提交的版本范围更窄:只移除会导致已确认 400 的内容(同 turn 级联),或者在任何上下文下都无条件无效的内容(文本为空的 thinking)。不再包含针对"有效性无法确定"的 turn 进行启发式重写。
  • 破坏性变更 / 迁移说明:对任何公开 API 均无破坏性变更。

关联 Issue

Fixes #8162

Palanisamy, Dinesh added 2 commits July 30, 2026 16:32
…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.
…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).
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR — and thanks to @wenshao for the deep live-verification that shaped this narrowed version.

Template looks good ✓

Problem: observed bug with evidence. The same-turn variant was hit live this session (the thinking blocks in the latest assistant message cannot be modified 400), and the linked issue #8162 documents the root cause through code reading. The maintainer's A/B verification (round 1 and round 2 in the comments) confirmed the behavior against a real Anthropic-protocol endpoint.

Direction: aligned — this is a correctness fix in the Anthropic content converter's orphan-cleanup pipeline, squarely within core mission. The PR was significantly narrowed after maintainer review: the broader cross-turn heuristic (pruneUntrustworthyThinking) was removed entirely after live testing showed it produced false positives and couldn't reproduce the state it targeted. What remains are two targeted fixes with clear justifications.

Size: 151 production lines (converter.ts: +146 −5), 267 test lines (converter.test.ts: +267 −0). Core paths touched (packages/core/src/**), but well under the 500-line threshold. No maintainer escalation needed on size.

Approach: the scope feels right. Two changes, both minimal: (1) cascade-strip thinking siblings in cleanOrphanedToolCalls when no tool_use survives the turn, and (2) a standalone dropEmptyTextThinkingBlocks guard for the redacted_thinking round-trip edge case. The cascade is correctly scoped to "no surviving tool_use" rather than "any removal" — the partial-orphan case is handled and tested. The pipeline ordering constraint (empty-text guard after the unsigned-thinking fail-fast) is well-documented and regression-tested. No unrelated changes or drive-by refactors.

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

Moving on to code review. 🔍

中文说明

感谢贡献!也感谢 @wenshao 的深度 live 验证塑造了这个精简版本。

模板完整 ✓

问题:已观测到的 bug,有证据。同 turn 变体在本次 session 中实际触发(thinking blocks in the latest assistant message cannot be modified 400 错误),关联 issue #8162 通过代码阅读记录了根因。维护者的 A/B 验证(评论中的第 1 轮和第 2 轮)在真实 Anthropic 协议端点上确认了该行为。

方向:对齐——这是 Anthropic 内容转换器孤儿清理管道中的正确性修复,完全在核心使命范围内。PR 在维护者审查后大幅收窄:更宽泛的跨 turn 启发式(pruneUntrustworthyThinking)在 live 测试显示其产生假阳性且无法复现其目标状态后被完全移除。保留的是两个有针对性的修复,理由清晰。

规模:151 行生产代码(converter.ts: +146 −5),267 行测试代码(converter.test.ts: +267 −0)。触及核心路径(packages/core/src/**),但远低于 500 行阈值。无需因规模升级维护者关注。

方案:范围合理。两个改动,都是最小化的:(1) 在 cleanOrphanedToolCalls 中,当该 turn 没有任何 tool_use 幸存时,级联移除 thinking 兄弟块;(2) 独立的 dropEmptyTextThinkingBlocks 保护逻辑,处理 redacted_thinking 往返转换的边界情况。级联正确地限定在"无幸存 tool_use"而非"任何移除"——部分孤儿情况已处理并有测试。管道顺序约束(空文本保护在 unsigned-thinking 快速失败之后)有良好文档和回归测试。无无关改动或顺手重构。

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

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 44fe5d76b486bfb4450313c3b2defa9642b7c7e9 · 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 400s when a thinking block's signature no longer matches after its sibling tool_use is orphaned), I would (1) extend cleanOrphanedToolCalls to track whether any tool_use was removed and whether none survives, then strip thinking/redacted_thinking siblings in that case, and (2) add a separate guard for the empty-text thinking edge case (redacted_thinking losing its data through Gemini-Part round-trip), placed after the unsigned-thinking fail-fast to avoid swallowing proxy-bug detection.

Comparison with the diff: the PR's approach matches this proposal closely. No simpler path missed.

The cascade logic in cleanOrphanedToolCalls is correct: toolUseRemoved is tracked inside the filter closure, survivingToolUse checks the post-filter state, and the thinking strip only fires on total removal. The message.role === 'assistant' guard is defensive but harmless. The dropEmptyTextThinkingBlocks function correctly finds the latest assistant turn by backward scan and exempts it, matching Anthropic's byte-exact replay contract. The DeepSeek exclusion (!options.injectThinkingOnToolUseTurns) is necessary — DeepSeek's synthetic thinking placeholder is deliberately empty-text and must survive.

Pipeline ordering is the subtle part, and it's handled well: the empty-text guard runs after dropUnsignedThinkingFromAssistantMessages, with a detailed comment explaining why (an empty-text unsigned block on an active tool-loop turn must hit the fail-fast throw, not be silently deleted first). The regression test for this ordering uses a two-step tool loop, which is the minimal fixture that can distinguish the two orderings — a one-step fixture can't because the latest-turn exemption fires regardless.

Tests: 7 new cases covering the cascade (fires / doesn't fire on partial orphan / empties the turn entirely), the pipeline ordering regression, and the empty-text guard (signed non-empty untouched / empty dropped / latest exempt). The test comments are unusually good — they explain the invariant being pinned, not just what the test does.

No critical blockers. No AGENTS.md violations. The comments are extensive but justified — this is a correctness-sensitive area where the ordering constraints and rejected alternatives are genuinely non-obvious.

Testing

Final CI results for 44fe5d7 (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,失败项排在最前。

The main unit suite (Test (ubuntu-latest, Node 22.x)) is still running on the reviewed commit. macOS/Windows tests and integration tests were skipped (likely gated on the ubuntu result). The precheck and labeling jobs passed. No failures to report — but the primary test evidence is not yet available.

Sandboxed verification would settle the remaining gap: @qwen-code /verify — that the cascade-strip in cleanOrphanedToolCalls actually prevents the Anthropic 400 on a real wire payload (and that the partial-orphan case preserves the thinking block) is a behavioural claim the unit tests model but cannot prove against the live API. This is a sponsored run (fork author lacks write access); a maintainer's @qwen-code /verify comment approves the head it was written against, and the run carries a pre-execution risk screen plus a full workspace wipe. Read the resulting report with the same skepticism as the fork's CI logs.

That said, the maintainer (@wenshao) has already done two rounds of live A/B verification against a real Anthropic-protocol endpoint (documented in the PR comments), which substantially de-risks the behavioural claim.

中文说明

代码审查

独立方案: 给定问题(thinking 块的签名在其兄弟 tool_use 被孤儿清理后不再匹配,导致 Anthropic 400),我会 (1) 扩展 cleanOrphanedToolCalls 来跟踪是否有 tool_use 被移除且无幸存,然后在该情况下移除 thinking/redacted_thinking 兄弟块;(2) 为空文本 thinking 边界情况(redacted_thinking 经过 Gemini-Part 往返丢失数据)添加独立保护,放在 unsigned-thinking 快速失败之后以避免吞掉代理 bug 检测。

与 diff 的比较: PR 的方案与此提案高度一致。没有遗漏更简路径。

cleanOrphanedToolCalls 中的级联逻辑正确:toolUseRemoved 在 filter 闭包内跟踪,survivingToolUse 检查过滤后状态,thinking 移除仅在完全移除时触发。dropEmptyTextThinkingBlocks 函数通过反向扫描正确找到最新 assistant turn 并豁免它。DeepSeek 排除(!options.injectThinkingOnToolUseTurns)是必要的。

管道顺序处理得当:空文本保护在 dropUnsignedThinkingFromAssistantMessages 之后运行,有详细注释说明原因。回归测试使用两步工具循环,是能区分两种顺序的最小 fixture。

测试:7 个新用例覆盖级联(触发/部分孤儿不触发/完全清空 turn)、管道顺序回归、和空文本保护。测试注释质量很高。

无关键阻塞。无 AGENTS.md 违规。

测试

主单元测试(ubuntu)仍在运行中。预检和标签任务通过。无失败——但主要测试证据尚不可用。

沙箱验证可以填补剩余空白:@qwen-code /verify——级联移除是否真正防止了真实 wire 载荷上的 Anthropic 400 是单元测试建模但无法对 live API 证明的行为性声明。这是赞助运行(fork 作者无写权限)。

不过,维护者(@wenshao)已经做了两轮针对真实 Anthropic 协议端点的 live A/B 验证(记录在 PR 评论中),这大幅降低了行为性声明的风险。

Qwen Code · qwen3.8-max-preview

Reviewed at 44fe5d76b486bfb4450313c3b2defa9642b7c7e9 · 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 — solid, narrow fix with comprehensive tests and two rounds of maintainer live-verification; only non-blocking nit is CI still running on the reviewed commit.

This PR went through exactly the right evolution: a broader heuristic was proposed, stress-tested by a maintainer against real infrastructure, found wanting, and stripped down to the two parts that are unconditionally correct. The result is better for it.

The cascade-strip in cleanOrphanedToolCalls is the right place for this logic — it's where the orphan removal happens, so it's where the signature-invalidation consequence should be handled. Scoping it to "no surviving tool_use" rather than "any removal" shows the author (and reviewer) actually thought through Anthropic's manual-mode contract rather than reaching for a blanket strip. The empty-text guard is a clean extraction of the one unconditionally valid piece from the removed heuristic, and the pipeline ordering is the kind of subtle correctness detail that's easy to get wrong — the regression test pinning it is genuinely valuable.

Every change in the diff is necessary for the stated goal. No drive-by refactors, no scope creep. The test-to-production ratio (~1.8:1) is appropriate for a correctness fix in a protocol converter. The comments explain rejected alternatives and ordering constraints that would otherwise be invisible — six months from now, a maintainer touching this pipeline will thank the author rather than curse them.

The one reservation keeping this at 4 rather than 5: the primary CI suite hasn't landed green on this commit yet, so the test evidence is the author's local run plus the maintainer's A/B verification rather than the repo's own CI. That's a timing issue, not a code issue.

Approval deferred until CI lands green on 44fe5d76b486bfb4450313c3b2defa9642b7c7e9.

中文说明

置信度:4/5 ——扎实的窄修复,测试全面,维护者做了两轮 live 验证;唯一的非阻塞小问题是 CI 仍在运行中。

这个 PR 经历了完全正确的演进:提出了一个更宽泛的启发式,由维护者在真实基础设施上压力测试,发现不足,然后精简到两个无条件正确的部分。结果因此更好。

cleanOrphanedToolCalls 中的级联移除是放置此逻辑的正确位置——孤儿移除发生在这里,所以签名失效的后果也应该在这里处理。将其限定在"无幸存 tool_use"而非"任何移除"表明作者(和审查者)真正思考了 Anthropic 手动模式的契约,而不是伸手去做全面移除。空文本保护是从被移除的启发式中干净地提取出的唯一无条件有效的部分,管道顺序是那种容易出错的微妙正确性细节——固定它的回归测试确实有价值。

diff 中的每个改动都是实现目标所必需的。无顺手重构,无范围蔓延。测试与生产代码比率(约 1.8:1)对于协议转换器的正确性修复是合适的。

唯一的保留意见是 4 而非 5:主 CI 套件尚未在此 commit 上绿色通过,所以测试证据是作者的本地运行加维护者的 A/B 验证,而非仓库自身的 CI。这是时序问题,不是代码问题。

审批推迟到 CI 在 44fe5d76b486bfb4450313c3b2defa9642b7c7e9 上绿色通过。

Qwen Code · qwen3.8-max-preview

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

… 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).
Comment on lines +186 to +188
if (!options.injectThinkingOnToolUseTurns) {
messages = pruneUntrustworthyThinking(messages);
}

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] pruneUntrustworthyThinking also runs on the DeepSeek thinking-OFF path (stripAssistantThinking), where it defeats stripThinkingFromAssistantMessages's deliberate thinking-only-turn passthrough. — Concrete cost: DeepSeek with thinking disabled sets stripAssistantThinking=true and injectThinkingOnToolUseTurns=false (anthropicContentGenerator.ts:669-670,702-705), so this guard passes and prune runs. For a non-latest thinking-only turn, prune re-types the thinking block to text; the second stripThinkingFromAssistantMessages pass then cannot remove it (it only filters thinking/redacted_thinking), and its deliberate filtered.length === 0 passthrough — which preserves the thinking type the strip author documented DeepSeek tolerates — never fires. Net: old reasoning is shipped as a visible assistant text utterance instead of a tolerated thinking block, a wire-shape change this diff introduces and the guard comment does not address. Verified with a probe: current code emits text, the fix below emits thinking.

Suggested change
if (!options.injectThinkingOnToolUseTurns) {
messages = pruneUntrustworthyThinking(messages);
}
if (!options.injectThinkingOnToolUseTurns && !options.stripAssistantThinking) {
messages = pruneUntrustworthyThinking(messages);
}
中文说明

pruneUntrustworthyThinking 在 DeepSeek 关闭 thinking 的路径(stripAssistantThinking)上同样会运行,从而破坏了 stripThinkingFromAssistantMessages 刻意保留的"纯 thinking turn 透传"行为。具体代价:DeepSeek 关闭 thinking 时会设置 stripAssistantThinking=trueinjectThinkingOnToolUseTurns=false(anthropicContentGenerator.ts:669-670,702-705),因此该守卫通过、prune 运行。对于非最新的纯 thinking turn,prune 会把 thinking 块重新标记为 text;随后第二次 stripThinkingFromAssistantMessages 无法移除它(它只过滤 thinking/redacted_thinking),而其刻意的 filtered.length === 0 透传——即保留 strip 作者记录的、DeepSeek 可容忍的 thinking 类型——永远不会触发。结果:旧的推理内容会作为一个可见的 assistant text 语句被发出,而不是一个被容忍的 thinking 块;这是本 diff 引入的 wire 形态变化,且守卫注释并未涉及。已用 probe 验证:当前代码输出 text,上方修复输出 thinking

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

Comment on lines +1142 to +1144
if (i === messages.length - 1) {
for (const block of toolUseBlocks.values()) {
validToolUseBlocks.add(block as object);

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] This new trailing-tool_use guard makes the Anthropic cleanOrphanedToolCalls intentionally diverge from the OpenAI one, but the function's doc still says "Mirrors the same-name function in the OpenAI converter." — Concrete cost: the OpenAI converter (openaiContentGenerator/converter.ts:1888) still strips a trailing tool call with no adjacent tool response. A future change that "re-syncs" the two implementations — or a reviewer reasoning from the "Mirrors" comment instead of this #8159 guard — could port the OpenAI trailing-strip back in and silently reintroduce the exact latest-turn-corruption / stale-thinking-signature 400 this PR fixes.

Suggested fix (the "Mirrors" sentence is pre-existing, so not a one-click suggestion on this hunk): amend that doc sentence to note the deliberate divergence, e.g. "Mirrors the same-name function in the OpenAI converter, except that a tool_use in the very last message is preserved as unresolved rather than stripped (see #8159); the OpenAI version still strips it."

中文说明

这个新增的尾部 tool_use 保护使 Anthropic 的 cleanOrphanedToolCalls 有意地与 OpenAI 版本产生分歧,但该函数的文档仍写着 "Mirrors the same-name function in the OpenAI converter"。具体代价:OpenAI 转换器(openaiContentGenerator/converter.ts:1888)仍会剥离没有相邻 tool response 的尾部 tool call。未来某个"重新同步"两份实现的改动——或者一个依据 "Mirrors" 注释而非此 #8159 守卫进行推理的 reviewer——可能会把 OpenAI 的尾部剥离逻辑移植回来,从而静默地重新引入本 PR 所修复的那个"最新 turn 被破坏 / thinking 签名过期"的 400。

建议修复("Mirrors" 那句是既有代码,故不在此 hunk 上提供一键 suggestion):修改该文档句以注明这一刻意分歧,例如:"Mirrors the same-name function in the OpenAI converter, except that a tool_use in the very last message is preserved as unresolved rather than stripped (see #8159); the OpenAI version still strips it."

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

Comment on lines +1215 to +1221
const finalBlocks =
toolUseRemoved && message.role === 'assistant'
? filtered.filter((b) => {
const t = (b as { type?: string }).type;
return t !== 'thinking' && t !== 'redacted_thinking';
})
: filtered;

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.

[Critical] The same-turn cascade strips DeepSeek's synthetic thinking placeholder on the injectThinkingOnToolUseTurns path, producing a DeepSeek 400. — Failure scenario: with DeepSeek thinking-ON (injectThinkingOnToolUseTurns: true), injectEmptyThinkingOnToolUseTurns (line ~164) prepends a synthetic {type:'thinking', thinking:'', signature:''} placeholder before cleanOrphanedToolCalls (line ~177). When a non-latest assistant turn carries multiple tool_use blocks and only some are orphaned (partial tool completion, compaction, or timeout), toolUseRemoved is true and this cascade strips all thinking from the turn — including the injected placeholder — leaving a surviving tool_use turn with no thinking block. DeepSeek then rejects the request: "The content[].thinking in the thinking mode must be passed back to the API" (see issue 3786). Before this PR there was no cascade, so the placeholder survived; no later pass re-injects it. The pruneUntrustworthyThinking guard at line ~186 skips exactly this path for exactly this reason, but the cascade does not respect the same constraint. Verified with a runnable probe: on current code the surviving tool_use turn has 0 thinking blocks; protecting the placeholder restores it.

Suggested fix (spans two locations, so not a one-click suggestion): either thread injectThinkingOnToolUseTurns into cleanOrphanedToolCalls and skip the thinking-strip when it is true (the synthetic placeholder's empty signature was never computed over the tool_use content, so the removal does not invalidate it), or re-run the injection after the cleanup:

messages = cleanOrphanedToolCalls(messages);
messages = injectEmptyThinkingOnToolUseTurns(messages); // restore placeholders the cascade stripped
中文说明

同一 turn 内的级联清理会在 injectThinkingOnToolUseTurns 路径上剥掉 DeepSeek 的合成 thinking 占位块,从而导致 DeepSeek 返回 400。失败场景:DeepSeek 开启 thinking(injectThinkingOnToolUseTurns: true)时,injectEmptyThinkingOnToolUseTurns(约第 164 行)会在 cleanOrphanedToolCalls(约第 177 行)之前预先插入一个合成的 {type:'thinking', thinking:'', signature:''} 占位块。当某个非最新 assistant turn 含有多个 tool_use 块、其中只有部分被判定为孤儿(部分工具完成、压缩或超时)时,toolUseRemoved 为 true,此级联会剥掉该 turn 中所有 thinking——包括刚插入的占位块——使得幸存的 tool_use turn 没有 thinking 块。DeepSeek 随即拒绝请求:"The content[].thinking in the thinking mode must be passed back to the API"(见 issue 3786)。本 PR 之前没有级联,占位块得以存活;后续也没有任何 pass 重新插入它。约第 186 行的 pruneUntrustworthyThinking 守卫正是出于这个原因显式跳过该路径,但级联没有遵守同样的约束。已用可运行 probe 验证:当前代码下幸存的 tool_use turn 上 thinking 块数为 0;保护占位块后得以恢复。

建议修复(涉及两处,故不提供一键 suggestion):要么把 injectThinkingOnToolUseTurns 传入 cleanOrphanedToolCalls,在其为 true 时跳过 thinking 剥离(合成占位块的空签名从未基于 tool_use 内容计算,因此移除 tool_use 不会使其失效);要么在清理之后重新执行注入。

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

Comment on lines +1321 to +1323
if (bType === 'thinking' && !hasSurvivingToolUse && !hasText) {
filtered.push({
type: 'text',

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 !hasSurvivingToolUse guard here is never the deciding factor in any test — a mutation that removes it survives the whole suite. — Concrete cost: no test creates a non-latest assistant turn with both a surviving tool_use and non-empty thinking (with pruning active). Delete !hasSurvivingToolUse && and every test still passes: the "real accompanying text" test is saved by !hasText, and the DeepSeek multi-turn tests skip pruning via the injectThinkingOnToolUseTurns guard. The surviving mutant would incorrectly downgrade thinking on a [thinking, tool_use] turn whose tool_use is still present (signature still valid) — a false-positive rewrite of valid historical content that CI would not catch.

Suggested fix (a new test, so not a one-click suggestion here): add a case in the pruneUntrustworthyThinking describe block — a non-latest assistant turn with non-empty thinking and a surviving tool_use (followed by a matching tool_result user message and a final assistant turn) — asserting the thinking block is preserved with its original type and signature.

中文说明

此处的 !hasSurvivingToolUse 守卫在任何测试中都不是决定性因素——一个移除该守卫的变异能在整套测试中存活。具体代价:没有任何测试构造一个同时含有幸存 tool_use 和非空 thinking 的非最新 assistant turn(且 prune 生效)。删掉 !hasSurvivingToolUse && 后所有测试仍通过:"含真实文本"测试由 !hasText 救下,DeepSeek 多 turn 测试则通过 injectThinkingOnToolUseTurns 守卫跳过 prune。存活的变异会错误地降级一个 [thinking, tool_use] turn 上的 thinking(该 turn 的 tool_use 仍在、签名仍有效)——这是对有效历史内容的假阳性重写,而 CI 无法捕获。

建议修复(新增测试,故此处不提供一键 suggestion):在 pruneUntrustworthyThinking 的 describe 块中新增一个用例——一个含有非空 thinking 含有幸存 tool_use 的非最新 assistant turn(其后跟一个匹配的 tool_result user 消息和一个最终 assistant turn)——断言 thinking 块以原始类型和签名被保留。

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

Comment on lines +1330 to +1332
if (bType === 'redacted_thinking' && !hasSurvivingToolUse && !hasText) {
continue;
}

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] This redacted_thinking drop branch has no real test coverage — deleting it leaves all 78 tests green (verified with a probe). — Concrete cost: the test named "drops an empty redacted_thinking-derived turn entirely" actually feeds {text:'', thought:true}, which processContent turns into an empty-text thinking block caught by the empty-text guard above (lines ~1311-1317) — it never reaches this branch (the test's own comment concedes the branch is "not independently reachable through this converter's own request-building path"). So the test title overstates coverage, and a future regression that stops dropping a literal redacted_thinking block (replaying a stale signature → 400) would pass CI. Lower priority since the branch is documented as defensive/unreachable today, but the gap and the misleading title are real.

Suggested fix (a new test): place a literal {type:'redacted_thinking', data:'opaque'} block on a non-latest assistant turn with no surviving tool_use and no text, and assert the block (and the turn, if it becomes empty) is dropped — exporting pruneUntrustworthyThinking for a direct unit test if needed.

中文说明

这个 redacted_thinking 丢弃分支没有真正的测试覆盖——删除它后全部 78 个测试仍为绿色(已用 probe 验证)。具体代价:名为 "drops an empty redacted_thinking-derived turn entirely" 的测试实际传入 {text:'', thought:true}processContent 会把它变成一个空文本的 thinking 块,被上方的空文本守卫(约 1311-1317 行)捕获——它永远不会到达此分支(该测试自己的注释也承认此分支 "not independently reachable through this converter's own request-building path")。因此测试标题夸大了覆盖范围,而未来某个停止丢弃字面 redacted_thinking 块的回归(重放过期签名 → 400)将能通过 CI。由于该分支今天被记录为防御性/不可达,优先级较低,但覆盖缺口和误导性标题是真实存在的。

建议修复(新增测试):在一个没有幸存 tool_use、也没有文本的非最新 assistant turn 上放置一个字面的 {type:'redacted_thinking', data:'opaque'} 块,断言该块(以及若变空则该 turn)被丢弃——必要时导出 pruneUntrustworthyThinking 以做直接单元测试。

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

Comment on lines +1336 to +1337
if (filtered.length === 0) continue;
out.push({ role: msg.role, content: filtered });

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] pruneUntrustworthyThinking silently rewrites and drops historical content with zero telemetry, unlike its sibling cleanOrphanedToolCalls (which logs at line ~1226) and the OpenAI converter's three debug lines. — Concrete cost: by this function's own doc it is a "conservative, false-positive-prone heuristic" that "will also downgrade a turn whose thinking signature was never actually invalidated … a real change to valid historical content." When a user reports "the model lost the thread of its earlier reasoning," nothing distinguishes "this pass rewrote/dropped the turn" from "the model answered differently" — the oncall must instrument the code and reproduce the exact history shape to confirm this pass fired.

Add a debug log mirroring the sibling, at both the downgrade branch above and the whole-message drop below:

Suggested change
if (filtered.length === 0) continue;
out.push({ role: msg.role, content: filtered });
if (filtered.length === 0) {
debugLogger.debug(
'pruneUntrustworthyThinking: dropping message with only untrustworthy thinking blocks',
);
continue;
}
out.push({ role: msg.role, content: filtered });
中文说明

pruneUntrustworthyThinking 在零遥测的情况下静默重写并丢弃历史内容,而其兄弟函数 cleanOrphanedToolCalls(约第 1226 行有日志)以及 OpenAI 转换器的三处 debug 日志都有遥测。具体代价:按本函数自身文档所述,它是一个"保守、易假阳性的启发式","也会降级一个 thinking 签名从未真正失效的 turn……这是对有效历史内容的真实修改"。当用户反馈"模型丢失了早先推理的脉络"时,没有任何东西能区分"是这个 pass 重写/丢弃了该 turn"还是"模型只是回答得不一样"——oncall 必须给代码加埋点并精确复现该历史形态,才能确认这个 pass 是否触发。

参照兄弟函数添加 debug 日志,分别在上方降级分支和下方整条消息丢弃处。

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

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Local verification report (maintainer)

I built this branch locally and ran it against a real client stack rather than reading the diff. Short version: PATCH-A is sound and I'd take it with one added guard; PATCH-B I'd split out and hold — your own calibration note turned out to be right, and the local runs also surfaced one concrete regression in it. Thanks for flagging the reproduction gap up front; that is what made this worth measuring instead of guessing.

What I ran

layer setup
build worktree at PR head 5d35aea vs its own base commit 65bd002 (= main + the cherry-picked #8163), so every delta below isolates PATCH-A/B and not #8163
unit packages/core/src/core/anthropicContentGenerator/198 pass on the PR; tsc --noEmit -p packages/core clean; eslint --max-warnings 0 clean on the touched dir
regression value the PR's converter.test.ts run against the base: 3 of the 5 new tests fail (same-turn cascade, cross-turn downgrade, redacted-derived drop). The other two are invariance tests and pass on both — expected, not a complaint
converter A/B 13 realistic session shapes pushed through the real AnthropicContentConverter on both builds, diffing the outbound request body
live E2E bundled CLI → real @anthropic-ai/sdk → a local server speaking the Anthropic Messages protocol and recording the exact wire body. claude-sonnet-4-5, thinking: {type:"enabled", budget_tokens:32000}, same TUI script driven against both builds

converter A/B matrix

Findings

✅ PATCH-A does exactly what it claims (S1). Orphaned tool_use stripped, thinking sibling goes with it, healthy tool-loop sessions are byte-identical (S7), the narrow-scope claim holds (S12), and the DeepSeek inject path is untouched (S9).

✅ One genuine fix is hiding inside PATCH-B (S6). On the base build a redacted-derived turn ships {"type":"thinking","thinking":""} with no signature field at all — the Messages API requires one. The PR drops it and the user turns merge cleanly. I'd keep this guard regardless of what happens to the rest of PATCH-B.

🔴 Pass ordering: pruneUntrustworthyThinking runs before the two passes whose whole job is deleting that content (S10). dropUnsignedAssistantThinking exists to remove unsigned thinking from replayed turns. Prune now runs first, re-types the block as text, and the drop pass no longer recognizes it — so unsigned reasoning imported from another provider (mid-session /model switch, forked agents, side-queries) is now sent to the model as assistant text where the base build removed the turn entirely. This fires on non-Anthropic-native base URLs with an adaptive-thinking model — i.e. your Vertex-routed proxy setup. I tested the fix: moving the pruneUntrustworthyThinking(...) call below the dropUnsignedAssistantThinking / stripAssistantThinking blocks restores base output for S10 with all 198 tests still green.

🟠 The DeepSeek exclusion is only half-applied (S11). The gate is !options.injectThinkingOnToolUseTurns, which is DeepSeek-with-thinking-on. DeepSeek-with-thinking-off goes through stripAssistantThinking, which deliberately leaves a thinking-only turn intact ("DeepSeek empirically tolerates the residual shape (verified against api.deepseek.com/anthropic)"). Prune isn't gated there, so it rewrites that deliberate passthrough. If DeepSeek is out of scope by design, the gate should be the provider, not one of its two modes.

🟠 PATCH-A has an unguarded partial-orphan case (S2). Turn = [thinking, tool_use A, tool_use B], only A's result came back. B is a genuine orphan and is stripped; A survives; the cascade removes the thinking anyway. The turn that goes on the wire is [tool_use A] with no thinking block, and it is the final assistant turn. Anthropic's docs: "In extended (manual) mode, the API additionally enforces that the final assistant turn of a thinking-enabled request begins with a thinking block. Adaptive mode relaxes this." We use manual mode for pre-4.6 Claude (confirmed on the wire above: budget_tokens on claude-sonnet-4-5), so on those models this shape can trade one 400 for another. Suggested guard: cascade only when no tool_use survives in that turn, plus a test for this shape. Honest limit: the wire shape is execution-confirmed here, the rejection itself is doc-derived — I have no Anthropic-native credentials on this box either.

🔴 PATCH-B's false positive is real, and I caught it on the wire in a live session, not in a fixture.

live A/B of request 3

Turn 1 was a thinking-only model reply that never carried a tool_use, so its signature was never invalidated. While it was still the latest assistant turn (request #2) both builds sent identical thinking blocks; one request later the PR build re-types it as text and drops the signature. Two costs follow: the serialized prefix of a live conversation changes between two consecutive requests, which is exactly what invalidates a cache breakpoint; and on last-turn-only models (Sonnet 4.5, Haiku 4.5 and earlier) the API strips prior-turn thinking for free, whereas text is context the model reads and you pay for.

🟠 The stated trigger for PATCH-B doesn't seem to exist in this codebase. Three things I checked by running them, not by reading:

  • Compaction can't leave a stale thinking block behind. chatCompressionService is full-history compression — "the entire curated history is sent to the summary side-query (no split, no tail preservation)" — and the post-compact history is summary + model ack + restores. No thinking block survives it, so there is no "compaction dropped the tool_use and left the thinking" state to clean up.
  • Orphans are already repaired upstream. repairOrphanedToolUseTurns (client.tsorphan_tool_use_repair) synthesizes an error functionResponse for any dangling call. I took a real recorded session, deleted the tool_result record from its JSONL (exactly the "stored history lost the result" state this PR is written for), resumed it with --continue on both builds, and both emitted an identical body containing a synthesized tool_result — no orphan ever reached the converter.
  • I also tried to manufacture an orphan the way a user would: pressing ESC mid-stream discards the partial turn outright (the next request carries no assistant turn at all). The converter's orphan handling is a second line of defence, which is fine — it just means both patches fire far less often than the PR assumes.

Docs corroboration for the calibration note: validation is scoped to "Within the latest assistant message, the sequence of consecutive thinking blocks must match what the model generated", and the guidance is explicit — "You don't need to prune old thinking yourself. Pass all thinking blocks back in multi-turn conversations, and the API automatically filters them." Note also "Allowed: outside tool use, omit prior turns' thinking" — omitting is sanctioned, re-typing into billed assistant text is not the same operation.

What I'd like to see before merge

  1. Split the PR. PATCH-A + the empty-thinking guard (S6) can land on their own; I'd approve that.
  2. PATCH-A: cascade only when the turn retains no tool_use, with a test for the partial-orphan shape (S2).
  3. PATCH-B, if you want to keep it as defensive hardening: move the call after the drop/strip passes (fixes S10, verified), gate on the DeepSeek provider rather than one option (S11), and prefer omitting the block over re-typing it as text — or narrow it to blocks with an empty/absent signature, which is the subset that is knowably invalid rather than merely indistinguishable.

None of this is a knock on the work — the code is careful, the tests are real regression tests, and the honesty about what you couldn't reproduce is exactly why this review could be short. It just lands on: half of it is ready, half of it is guarding a state this codebase doesn't produce.

Test rig (screenshot)

The live runs drive the real TUI against a local Anthropic-protocol endpoint, so history recording, the converter and the SDK are all exercised; the server logs the request body verbatim for the A/B.

qwen-code TUI against the fake Anthropic endpoint

中文说明

本地验证报告(maintainer)

我在本地把这个分支构建出来,用真实的客户端链路跑了一遍,而不是只读 diff。结论先说:PATCH-A 是站得住的,补一个 guard 之后我可以接受;PATCH-B 我建议拆出去先搁置 —— 你自己写的那段校准说明结果是对的,而且本地跑下来还额外发现了它引入的一处真实回归。感谢你把复现的缺口提前标出来,正因为如此这次才值得实测而不是靠推断。

我跑了什么

层次 设置
构建 worktree:PR HEAD 5d35aea vs 它自己的 base commit 65bd002(= main + cherry-pick 的 #8163),因此下面所有差异都只反映 PATCH-A/B,不含 #8163
单测 packages/core/src/core/anthropicContentGenerator/ → PR 上 198 通过tsc --noEmit -p packages/core 干净;改动目录 eslint --max-warnings 0 干净
回归价值 把 PR 的 converter.test.ts 拿到 base 上跑:5 个新测试里有 3 个失败(同 turn 级联、跨 turn 降级、redacted 衍生场景丢弃)。另外 2 个是不变性测试,两边都通过 —— 这是正常的,不是挑刺
转换器 A/B 13 个真实会话形态,走真实的 AnthropicContentConverter,在两个构建上对比出站请求体
Live E2E 打包后的 CLI → 真实 @anthropic-ai/sdk → 本地实现 Anthropic Messages 协议并逐字记录 wire body 的服务端。claude-sonnet-4-5thinking: {type:"enabled", budget_tokens:32000},同一套 TUI 脚本分别驱动两个构建

converter A/B matrix

结论

✅ PATCH-A 完全符合它的描述(S1)。 孤儿 tool_use 被剥离、thinking 兄弟块随之移除;健康的 tool-loop 会话逐字节不变(S7);"更窄范围"的说法成立(S12);DeepSeek inject 路径未受影响(S9)。

✅ PATCH-B 里藏着一个真实的修复(S6)。 base 构建上,redacted 衍生的那个 turn 会把 {"type":"thinking","thinking":""} 发到线上,而且完全没有 signature 字段 —— 而 Messages API 是要求这个字段的。PR 把它丢弃了,user turn 也干净地合并了。无论 PATCH-B 其余部分如何处理,这个 guard 我都建议保留。

🔴 Pass 顺序问题:pruneUntrustworthyThinking 跑在了那两个"专门负责删掉这些内容"的 pass 前面(S10)。 dropUnsignedAssistantThinking 的职责就是在重放历史时移除无签名的 thinking。现在 prune 先跑,把这个块改写成了 text,drop pass 就再也认不出它了 —— 于是从别的 provider 带过来的无签名推理内容(会话中途 /model 切换、forked agent、side-query)现在会以 assistant 文本的形式发给模型,而 base 构建是把整个 turn 删掉的。触发条件是"非 Anthropic 原生 baseURL + adaptive thinking 模型",也就是你那套走 Vertex 代理的环境。我实测了修复方案:把 pruneUntrustworthyThinking(...) 调用挪到 dropUnsignedAssistantThinking / stripAssistantThinking 之后,S10 即恢复成 base 的输出,198 个测试依然全绿。

🟠 DeepSeek 的豁免只做了一半(S11)。 现在的 gate 是 !options.injectThinkingOnToolUseTurns,对应的是 DeepSeek + thinking 开启。DeepSeek + thinking 关闭走的是 stripAssistantThinking,而那个 pass 是故意保留纯 thinking turn 的(注释里写着"DeepSeek empirically tolerates the residual shape(已针对 api.deepseek.com/anthropic 验证)")。prune 在这条路径上没有被 gate 住,于是把这个刻意的 passthrough 改写掉了。如果 DeepSeek 按设计不在本次范围内,那 gate 应该基于 provider,而不是它两种模式中的一种。

🟠 PATCH-A 有一个没有防护的"部分孤儿"场景(S2)。 turn = [thinking, tool_use A, tool_use B],只有 A 的结果回来了。B 是真正的孤儿,会被剥离;A 存活;级联却仍然把 thinking 移除了。最终发到线上的这个 turn 是 [tool_use A],没有 thinking 块,而它正是最后一个 assistant turn。Anthropic 文档原文:"In extended (manual) mode, the API additionally enforces that the final assistant turn of a thinking-enabled request begins with a thinking block. Adaptive mode relaxes this." 我们对 4.6 之前的 Claude 用的正是 manual 模式(上面的 wire 抓包可证:claude-sonnet-4-5 走的是 budget_tokens),所以在这些模型上,这个形态可能是用一个 400 换来另一个 400。建议的 guard:只有当该 turn 中没有任何 tool_use 存活时才级联,并补一个针对这个形态的测试。诚实地说明边界:wire 形态是实跑确认的,但那个 400 本身是从文档推出来的 —— 我这台机器上同样没有 Anthropic 原生凭据。

🔴 PATCH-B 的假阳性是真实存在的,而且我是在一次 live 会话的 wire 上抓到的,不是在 fixture 里。

live A/B of request 3

turn 1 是一次纯 thinking 的模型回复,它从来没有携带过 tool_use,因此签名从未失效。当它还是最后一个 assistant turn 时(request #2),两个构建发出的 thinking 块完全一致;再过一个请求,PR 构建就把它改写成了 text 并丢掉了签名。由此带来两项代价:一次进行中的会话,其序列化前缀在相邻两个请求之间发生了变化,这恰恰是让 cache breakpoint 失效的原因;而在 last-turn-only 的模型上(Sonnet 4.5、Haiku 4.5 及更早),API 本来会免费剥离历史 thinking,改成 text 之后它就变成了模型要读、你要付费的上下文。

🟠 PATCH-B 所描述的触发条件,在这个代码库里似乎并不存在。 有三件事我是跑出来的,不是读出来的:

  • 压缩不可能留下过期的 thinking 块。 chatCompressionService 是全历史压缩 —— "the entire curated history is sent to the summary side-query (no split, no tail preservation)" —— 压缩后的历史是 summary + model ack + 恢复内容。没有任何 thinking 块能存活,所以根本不存在"压缩丢掉了 tool_use、留下了 thinking"这种状态需要清理。
  • 孤儿在上游就已经被修复了。 repairOrphanedToolUseTurnsclient.tsorphan_tool_use_repair)会为任何悬空的调用合成一个 error functionResponse。我拿了一次真实录制的会话,把它 JSONL 里的 tool_result 记录删掉(正是本 PR 所针对的"存储历史丢失了 result"这一状态),再用 --continue 在两个构建上恢复,两边产出的请求体完全一致,且其中带着一个合成出来的 tool_result —— 没有任何孤儿走到转换器这一层。
  • 我还试着用用户的方式制造孤儿:流式过程中按 ESC,部分 turn 会被整个丢弃(下一个请求里根本没有 assistant turn)。转换器里的孤儿处理是第二道防线,这本身没问题 —— 只是说明这两个 patch 的实际触发频率远低于 PR 的假设。

文档层面对那段校准说明的印证:校验的范围是 "Within the latest assistant message, the sequence of consecutive thinking blocks must match what the model generated",而且指引写得很直接 —— "You don't need to prune old thinking yourself. Pass all thinking blocks back in multi-turn conversations, and the API automatically filters them." 另外注意 "Allowed: outside tool use, omit prior turns' thinking" —— 允许的是"省略",而"改写成会计费的 assistant 文本"并不是同一个操作。

合并前我希望看到的

  1. 拆分 PR。 PATCH-A + 空 thinking 的 guard(S6)可以单独合入,这部分我会 approve。
  2. PATCH-A:只有在该 turn 没有任何 tool_use 存活时才级联,并补上针对部分孤儿形态(S2)的测试。
  3. PATCH-B(如果你还是想把它作为防御性加固保留):把调用挪到 drop/strip 两个 pass 之后(可修复 S10,已验证);gate 基于 DeepSeek provider 而非单个 option(S11);并且优先选择省略该块而不是把它改写成 text —— 或者把作用范围收窄到签名为空/缺失的那些块,那才是可判定为无效的子集,而不是仅仅"无法区分"的那一类。

以上都不是对这份工作的否定 —— 代码写得很细致,测试是真正的回归测试,而你对"哪些没能复现"的坦诚正是这次 review 能这么快收敛的原因。只是结论落在:一半已经可以合了,另一半守的是这个代码库并不会产生的状态。

测试装置(截图)

live 验证是用真实 TUI 打到本地的 Anthropic 协议端点上,因此历史记录、转换器和 SDK 都被真实执行;服务端逐字记录请求体用于 A/B 对比。

qwen-code TUI against the fake Anthropic endpoint


Verified locally with Claude Code (model: Claude Opus 5, 1M context).

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Resolve the converter.test.ts conflict by keeping this PR's cascade-strip test alongside the trailing-tool_use test that main landed independently.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Merge resolution — PR #8166

Root cause. This PR's first commit (65bd002b7, "don't strip a trailing tool_use") was independently landed on main inside PR #8164 (adfdd33ae), which added the same trailing-tool_use guard, doc paragraph, and regression test at the same anchor (before it('cleans orphaned tool_result blocks…')). Both sides had that test; HEAD also inserts its own cascade-strips test right after it — that boundary is the conflict.

Textual, not semantic. Only converter.test.ts conflicted, positionally. Git merged the shared "trailing tool_use" test as common text (byte-identical both sides); the conflict was just the boundary — HEAD continues with its extra test, main ends (empty theirs). Resolution keeps HEAD's test:

      expect(assistantMsg!.content).toEqual([
        { type: 'text', text: 'Let me help' },
      ]);
    });

converter.ts auto-merged clean. Pipeline is coherent: pruneUntrustworthyThinking (this PR) runs after cleanOrphanedToolCalls, before stripTrailingAssistantPrefill (main) — independent gated passes.

Load-bearing. The cascade-strips test must sit between the trailing-tool_use test and cleans orphaned tool_result blocks; its expect array is closed by the ]);/}); after the conflict region, so the resolution keeps HEAD through { type: 'text', text: 'Let me help' }, and reuses that closing. No duplicate tests/definitions remain.

Could not verify. No build/typecheck/tests run. Two notes:

  • converter.ts's auto-merge left a duplicated JSDoc paragraph in cleanOrphanedToolCalls's doc ("A tool_use in the very last message…" twice, ~lines 1420–1436). Kept byte-identical to git's clean auto-merge per the "only edit conflicted files" rule; harmless, removable later.
  • Checked, not runtime-verified: main's stripTrailingAssistantPrefill appends a synthetic user "Continue." turn after a trailing assistant message; pruneUntrustworthyThinking preserves the latest assistant turn but runs first, so detection is unaffected. No non-conflicted test/caller depends on changed behavior.
中文说明

根因。 本 PR 首个提交(65bd002b7)已被独立合入 main 的 PR #8164adfdd33ae):它在同一锚点(cleans orphaned tool_result blocks 之前)加入了相同的尾部 tool_use 保护、文档段落与回归测试。双方都有该测试,而 HEAD 在其后额外插入 cascade-strips 测试,冲突即在此边界。

仅文本冲突。 只有 converter.test.ts 位置性冲突。Git 已将共享的 "trailing tool_use" 测试作为相同文本合并(两侧逐字节一致);冲突仅是边界——HEAD 继续其额外测试,main 结束(theirs 为空)。解决方式保留 HEAD 的测试(见上)。converter.ts 自动合并无标记;管线一致:pruneUntrustworthyThinkingcleanOrphanedToolCalls 之后、stripTrailingAssistantPrefill(main)之前运行,独立且受开关控制。

关键点。 cascade-strips 测试须位于 trailing-tool_use 测试与 cleans orphaned tool_result blocks 之间;其 expect 数组由冲突区域后的 ]);/}); 闭合,故保留 HEAD 至 { type: 'text', text: 'Let me help' }, 并复用该闭合。无重复测试名或定义。

未能验证。 未运行构建/类型检查/测试。两点:

  • converter.ts 自动合并在 cleanOrphanedToolCalls 文档留下重复 JSDoc 段落("A tool_use in the very last message…" 两次,约 1420–1436 行)。按"仅编辑冲突文件"规则保持与 git 干净自动合并逐字节一致;无害,可后续删除。
  • 已检查未运行时验证:main 的 stripTrailingAssistantPrefill 在尾部 assistant 后追加合成 user "Continue." 轮次;pruneUntrustworthyThinking 保留最新 assistant 轮次但先运行,判定不受影响。未见非冲突测试/调用方依赖被改变的行为。

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

[Critical] Re-checked against the current HEAD (abe0c01) — STILL STANDS. The same-turn cascade added to cleanOrphanedToolCalls strips ALL thinking/redacted_thinking from an assistant turn whenever ANY tool_use is removed, with no injectThinkingOnToolUseTurns guard. Because injectEmptyThinkingOnToolUseTurns (converter.ts:264) runs BEFORE cleanOrphanedToolCalls, on the DeepSeek thinking-ON path the injected synthetic {type:'thinking',thinking:'',signature:''} placeholder is stripped when a sibling tool_use is partially orphaned, leaving a surviving tool_use turn with no thinking block → DeepSeek 400 ("The content[].thinking in the thinking mode must be passed back to the API", issue 3786). The 5 commits since the original comment (3686768345) gated only the new pruneUntrustworthyThinking call, not the cascade. The maintainer's wire-verified "S2" is the same unguarded cascade viewed from the Anthropic manual-mode angle (a final assistant turn of a thinking-enabled request must begin with a thinking block). Suggested fix: cascade only when NO tool_use survives in that turn, or thread injectThinkingOnToolUseTurns into cleanOrphanedToolCalls and skip the thinking-strip when it is true (the synthetic placeholder's empty signature was never computed over the tool_use content, so removing the tool_use does not invalidate it).

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

Comment on lines +1418 to +1420
* modified". The model regenerates thinking on its next turn regardless.
*
* A `tool_use` in the very last message (no message follows it at all) is

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 JSDoc paragraph that starts here ("A tool_use in the very last message…") is a verbatim duplicate of the paragraph immediately below it. This diff inserted a new copy directly above the pre-existing one, so cleanOrphanedToolCalls's doc now contains the same eight-line paragraph twice in a row (lines ~1420–1427 and ~1429–1436). — Concrete cost: a future edit to the last-message exemption (e.g. refining the "no result yet" semantics) can update one copy and leave the other stale, making the function's contract ambiguous, and every reader has to parse the same paragraph twice.

Fix: delete one of the two identical copies so a single "A tool_use in the very last message…" paragraph remains.

中文说明

从这里开始的 JSDoc 段落("A tool_use in the very last message…")与紧接在它下方的段落逐字重复。本次 diff 在已有段落上方又插入了一份完全相同的副本,因此 cleanOrphanedToolCalls 的文档现在连续出现了两遍同一段八行文字(约第 1420–1427 行与第 1429–1436 行)。具体代价:将来修改"最后一条消息豁免"策略(例如细化 "no result yet" 的语义)时,可能只更新其中一份而让另一份过期,从而使该函数的契约变得含糊;每个读者也都不得不把同一段话读两遍。

修复:删除两份完全相同的副本中的一份,只保留一个 "A tool_use in the very last message…" 段落。

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

Comment on lines +1546 to +1548
const finalBlocks =
toolUseRemoved && message.role === 'assistant'
? filtered.filter((b) => {

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 cascade's "turn emptied → whole message dropped" branch has no test. The only cascade test ("cascade-strips a signed thinking block when its sibling tool_use is orphaned in the same pass", converter.test.ts:1151) gives the turn a surviving text block, so finalBlocks is never empty and the else drop branch here is never exercised by the cascade. — Concrete cost: the untested variant is a same-turn assistant turn whose only blocks are a signed thinking plus an orphaned tool_use (no surviving text). The cascade strips the thinking, finalBlocks becomes empty, and the if (finalBlocks.length > 0) guard drops the message — a path this diff newly creates (pre-diff the thinking survived). A regression here (pushing a content: [] message, or failing to drop the emptied turn so an invalid-signature thinking block is replayed and Anthropic 400s) would ship undetected.

Fix: add a test — a model turn with only { text: 'reasoning', thought: true, thoughtSignature: 'sig' } plus an orphaned functionCall, followed by a user message lacking the matching tool_result and surrounded by two user messages; assert the assistant turn is gone and the two surrounding user messages are merged.

中文说明

级联清理中"turn 被清空 → 整条消息被丢弃"的分支没有测试覆盖。唯一的级联测试("cascade-strips a signed thinking block when its sibling tool_use is orphaned in the same pass",converter.test.ts:1151)给该 turn 保留了一个 text 块,因此 finalBlocks 永远不会为空,这里的 else 丢弃分支也就永远不会被级联触发。具体代价:未被测试的形态是这样一个 assistant turn——它仅有的块是一个带签名的 thinking 加一个孤儿 tool_use(没有幸存的文本)。级联会剥掉 thinking,finalBlocks 变为空,if (finalBlocks.length > 0) 守卫随即丢弃该消息——这是本 diff 新引入的路径(改动前 thinking 会存活)。此处的回归(例如推出一条 content: [] 消息,或未能丢弃被清空的 turn 导致一个签名失效的 thinking 块被重放、从而 Anthropic 返回 400)将不会被检测到。

修复:补一个测试——一个 model turn 只含 { text: 'reasoning', thought: true, thoughtSignature: 'sig' } 加一个孤儿 functionCall,其后跟一条缺少对应 tool_result 的 user 消息,并在两侧各有一条 user 消息;断言该 assistant turn 被移除、且两侧的两条 user 消息被合并。

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

…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).
@netbrah netbrah changed the title fix(anthropic): prune stale thinking signatures after a sibling tool_use is removed fix(anthropic): cascade-strip stale thinking siblings when their tool_use is orphaned Jul 31, 2026
@netbrah

netbrah commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@wenshao Pushed the narrowed version per your review — thank you for the depth here, especially tracing the actual reachability of the cross-turn state through chatCompressionService and repairOrphanedToolUseTurns rather than reasoning about it abstractly. Summary of what changed (full detail in the updated PR description):

  • Kept the same-turn cascade (PATCH-A), fixed the partial-orphan gap (S2): it now only fires when no tool_use survives the turn.
  • Kept the empty-text guard (S6), extracted into its own dropEmptyTextThinkingBlocks function.
  • Dropped the broader cross-turn heuristic (PATCH-B) entirely rather than fixing and re-shipping it — the state it exists to clean up didn't reproduce against this codebase's actual compaction/orphan-repair machinery, and carrying it forward would mean shipping error handling for a scenario that can't currently occur.
  • One more pass-ordering hazard of the exact same class you flagged turned up during a follow-up review round: the extracted dropEmptyTextThinkingBlocks guard, if it ran before dropUnsignedThinkingFromAssistantMessages, could silently delete an empty-text unsigned thinking block before that pass's active-tool-loop fail-fast ever saw it — swallowing the exact proxy bug that throw exists to surface. Fixed by reordering, with a regression test that fails without the fix.
  • Also fixed the two open bot Suggestions on the diff (a duplicated JSDoc paragraph, missing coverage for the empty-turn-drop branch).

97/97 tests pass, tsc and eslint clean. Ready for another look whenever you have time.

@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. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

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

]);
});

it('drops an empty redacted_thinking-derived turn entirely (defensive, no plaintext fallback)', () => {

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] No test covers the partial-filter branch of dropEmptyTextThinkingBlocks — a non-latest assistant turn where the empty-text thinking block is stripped but a sibling (e.g. text) survives and the message is kept via out.push({ role: msg.role, content: filtered }) (converter.ts:1662). — Concrete cost: a mutation out.push({ role: msg.role, content: filtered })out.push(msg) survives the entire suite (probe-verified): the "leaves a signed, non-empty thinking block" test's filter is a no-op (filtered === msg.content), the "drops an empty redacted_thinking-derived turn entirely" test below takes the filtered.length === 0 branch and never reaches the push, and the latest-turn test takes the early return. A non-latest turn shaped [{ text: '', thought: true }, { text: 'hello' }] distinguishes them — correct code emits [{ type: 'text', text: 'hello' }], the mutant emits [{ type: 'thinking', thinking: '' }, { type: 'text', text: 'hello' }], sending an invalid empty-text thinking block on the wire. Add a test next to this one:

it('removes an empty-text thinking block but keeps sibling text on a non-latest turn', () => {
  const { messages } = converter.convertGeminiRequestToAnthropic({
    model: 'models/test',
    contents: [
      { role: 'user', parts: [{ text: 'Hi' }] },
      { role: 'model', parts: [{ text: '', thought: true }, { text: 'Hello there' }] },
      { role: 'user', parts: [{ text: 'anything else?' }] },
      { role: 'model', parts: [{ text: 'Sure.' }] },
    ],
  });
  const olderAssistant = messages[1];
  expect(olderAssistant.role).toBe('assistant');
  expect(olderAssistant.content).toEqual([{ type: 'text', text: 'Hello there' }]);
});
中文说明

没有测试覆盖 dropEmptyTextThinkingBlocks 的部分过滤分支——即某个非最新 assistant turn 中文本为空的 thinking 块被剥离、但兄弟块(如 text)幸存、消息通过 out.push({ role: msg.role, content: filtered })(converter.ts:1662)被保留的情形。具体代价:将 out.push({ role: msg.role, content: filtered }) 突变为 out.push(msg) 后,整个测试套件仍能通过(已用 probe 验证):"leaves a signed, non-empty thinking block" 测试的过滤是空操作(filtered === msg.content);下方 "drops an empty redacted_thinking-derived turn entirely" 测试走 filtered.length === 0 分支、永远到不了 push;最新 turn 测试走提前返回。形如 [{ text: '', thought: true }, { text: 'hello' }] 的非最新 turn 可以区分二者——正确代码输出 [{ type: 'text', text: 'hello' }],突变体输出 [{ type: 'thinking', thinking: '' }, { type: 'text', text: 'hello' }],从而把一个无效的文本为空 thinking 块发到线上。建议在旁边补充一个测试(见上方代码块)。

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

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — round 2 (maintainer)

I rebuilt this branch from scratch and re-ran the whole verification against the narrowed revision (f1a36f6), rather than re-reading the diff. Short version: all three findings from my previous round are genuinely fixed, the remaining change is correct, and I'd merge it. One residual boundary and one reachability note below — neither blocks.

What I ran

layer setup
build two clean worktrees + npm ci: PR head f1a36f6 and its merge-base 7918717; plus the PR's previous revision 5d35aea (the one I reviewed on Jul 31) as a third data point
unit converter.test.ts on the head; the head's test file replayed against the merge-base implementation as a test-efficacy check; the full anthropicContentGenerator/ suite; npm run typecheck; eslint; npm run bundle
differential a 9-scenario probe driving only the public convertGeminiRequestToAnthropic entry point, so the identical file runs on all three revisions, diffing the outbound Anthropic message array
live real AnthropicContentGenerator + real @anthropic-ai/sdk + real HTTP/SSE over a socket — no SDK mocks — against a strict Anthropic-protocol server I wrote that enforces the actual validation rules
end-to-end the bundled dist/cli.js from the PR head, driving a full tool round trip against that same server

The strict server is the part that makes the live layer meaningful: a thinking signature is issued as an HMAC over a canonical serialization of the whole assistant turn (thinking text + every tool_use id/name/input + text) and re-verified on the next request. Removing a sibling tool_use and replaying the same thinking block therefore fails for the same structural reason it fails at api.anthropic.com, producing the same error string. It also enforces role alternation, empty content arrays, tool_use/tool_result pairing, missing signatures, and the manual-mode "final assistant turn with a tool_use must begin with thinking" rule.

1. My previous round's findings are fixed — verified, not assumed

Same probe, run against 5d35aea and against f1a36f6:

previous revision vs narrowed head

finding on 5d35aea on f1a36f6
S2 — partial-orphan gap turn [thinking, tool_use A, tool_use B] with only B orphaned: the cascade stripped the thinking anyway, even though A still ships → trades the signature 400 for the "final turn must start with a thinking block" 400 thinking preserved; cascade only fires when no tool_use survives ✅
pruneUntrustworthyThinking false positive a thinking-only turn that never carried a tool_use — signature never invalidated — got re-typed to plain text one request later purely because a newer assistant turn displaced it as "latest" (breaks a cache breakpoint, costs tokens on last-turn-only models) untouched, byte-identical ✅
pass-ordering hazard an empty-text unsigned thinking block on a non-latest step of an active tool loop was silently swallowed — dropUnsignedThinkingFromAssistantMessages exists to fail loudly there and no longer recognised the block throws the intended proxy error ✅

That third one is worth calling out: the fix wasn't just deleting the broken heuristic. The same hazard applied to the extracted empty-text guard, and you caught it in your own follow-up round and moved the guard after the fail-loud check. That's the right call and it's the change I'd have asked for.

2. Behaviour matrix — merge-base vs head

A/B matrix

3 of 9 scenarios change; the other 6 are pinned as guards.

# scenario verdict
S1 [thinking(signed), tool_use A], A orphaned changed — base leaves a stale-signed thinking block behind; head removes both and merges the surrounding user turns
S2 [thinking, tool_use A, tool_use B], only B orphaned unchanged — thinking correctly survives
S3 cascade empties the turn out entirely changed — message dropped, adjacent user turns merged (no double-user 400)
S4 empty-text thinking on a non-latest turn, native Anthropic changed — dropped; the real signed thinking on the next turn is untouched
S5 same block on the latest turn unchanged — exempt, as it must be
S6 thinking-only turn that never had a tool_use unchanged — the removed heuristic's false positive is gone
S7 DeepSeek injectThinkingOnToolUseTurns placeholder unchanged — {thinking:'', signature:''} preserved on every tool-use turn
S8 empty-text unsigned thinking, active tool loop, proxy unchanged — still throws
S9 same, completed tool loop, proxy unchanged — safely removed by the existing pass

S7 matters more than it looks. dropEmptyTextThinkingBlocks is gated on !injectThinkingOnToolUseTurns, and DeepSeek's placeholder is literally an empty-text thinking block — without that gate this guard would strip the exact block DeepSeek's API demands. The gate is present and verified on the wire.

S4's gating is also worth stating explicitly, because it's what makes this guard non-redundant: dropUnsignedAssistantThinking is switched off for a native api.anthropic.com base URL (!isAnthropicNativeBaseUrl(...) in anthropicContentGenerator.ts). A redacted_thinking block that round-trips through Part conversion comes back as {type:'thinking', thinking:''} with no signature — unsigned by that pass's own definition, but that pass isn't running. So on the native path nothing else removes it. This guard is the only thing that does.

3. Live A/B over a real socket

live HTTP A/B

Turn 1 is a real streaming request; the server answers with a signed thinking block plus a sibling tool_use, and the client records it through the real Anthropic→Gemini conversion, consolidated exactly the way GeminiChat.processStreamResponse does it. The tool call is then cancelled, so turn 2 replays a turn whose tool_use is a genuine orphan.

4. End-to-end CLI session

real CLI session

The bundled dist/cli.js from the PR head completes a full tool round trip against the strict server. Four requests, all 200, every signed thinking block replayed byte-exact across turns. No regression on the ordinary path.

5. Build, tests, test efficacy

build and tests

97/97 in converter.test.ts (matching your reviewer test plan), 222/222 across the whole anthropicContentGenerator/ suite, typecheck and eslint clean, bundle builds.

On test efficacy: I replayed the head's test file against the merge-base implementation. 3 of the 7 new tests fail therecascade-strips a signed thinking block…, drops the whole message and merges surrounding user turns…, and drops an empty redacted_thinking-derived turn entirely. The other 4 pass on the merge-base too, because they pin behaviour the base already has: they're guards against the previous revision's regressions (partial-orphan, latest-turn exemption, pass ordering, false positive) rather than tests of new behaviour. That's the correct shape for this PR — I'd have asked for exactly those guards — but worth stating so nobody reads "7 new tests" as "7 new behaviours".

Two notes, neither blocking

Reachability of the same-turn cascade. I went looking for a path where the converter's cleanOrphanedToolCalls actually sees an orphaned tool_use today, and on the main client I could not find one. repairOrphanedToolUseTurns runs twice — once at session load (client.ts) and again inside sendMessageStream after the user content is pushed and after compression has settled — and it walks the entire history, not just the tail, synthesizing a functionResponse for every dangling tool_use. slimCompactionInput is a strict 1:1 part map and can't strand anything. Subagents go through the same GeminiChat.sendMessageStream. I also tried to produce the state organically: a real CLI session running a long shell tool, SIGINT'd mid-call, then resumed with --continue — the cancellation path synthesizes a tool_result, so no orphan appears.

I'm raising this because it's the same argument that (correctly) killed PATCH-B, and it deserves an answer rather than being quietly asymmetric. The answer is that these are different kinds of code. PATCH-B was a speculative cross-turn heuristic inferring an unobservable past state from an ambiguous present one. This is a local self-consistency invariant of a single function: whenever cleanOrphanedToolCalls removes the last tool_use from a turn — for whatever reason it fires, now or later — leaving the thinking sibling behind is a latent 400 created by this pass itself. It costs ~15 lines, has no false-positive mode (it only triggers on a removal this function just performed), and closes the class of bug that already bit us via a different orphan-cleanup implementation in #8159/#8163. That's worth keeping even with today's reachability at zero on the main path.

Residual boundary: the latest-turn exemption. An empty-text redacted_thinking-derived block on the latest assistant turn still goes out on the wire (S5), and native Anthropic will reject it. This is pre-existing, identical on the merge-base, and there's no winning move inside this converter: with a tool_use present, removing the block trades the "must have a signature" 400 for the "must begin with a thinking block" 400. The real fix is upstream — not losing redacted_thinking's opaque data in convertAnthropicResponseToGemini's Part round trip (it currently becomes {text:'', thought:true}, discarding data outright). Out of scope here; worth a follow-up issue.

Minor, non-blocking: dropEmptyTextThinkingBlocks rebuilds messages as {role, content} while the sibling passes use {...message, content}. MessageParam has no other fields today so it's harmless, but the spread would be more consistent.

Verdict

Approve. This is now the narrow, provable version of the change: it only removes content that would otherwise cause a confirmed 400, or that is unconditionally invalid regardless of context. Every concern from my previous round reproduces on the old revision and is resolved on this one, and the DeepSeek and active-tool-loop boundaries hold on the wire. Thanks for taking the reproduction question seriously instead of arguing the contract — cutting your own change down to its provable core on review evidence is the harder and better call.

中文版本

本地验证报告 —— 第 2 轮(维护者)

我从零重新构建了这个分支,针对收窄后的版本(f1a36f6)重跑了完整验证,而不是重读 diff。结论:我上一轮提的三个问题都已真正修复,剩下的改动是正确的,我同意合并。 下面有一个残留边界和一个可达性说明,都不阻塞合并。

我跑了什么

层次 配置
构建 两个干净 worktree + npm ci:PR head f1a36f6 与其 merge-base 7918717;另外把 PR 的上一版 5d35aea(我 7 月 31 日 review 的那版)作为第三个对照点
单测 head 上跑 converter.test.ts;把 head 的测试文件放到 merge-base 实现上重放做测试有效性检验;完整 anthropicContentGenerator/ 套件;npm run typecheckeslintnpm run bundle
差分 一个 9 场景探针,只调用公开入口 convertGeminiRequestToAnthropic,因此同一个文件能在三个版本上原样运行,对比出站 Anthropic 消息数组
Live 真实 AnthropicContentGenerator + 真实 @anthropic-ai/sdk + 真实 HTTP/SSE 走 socket —— 无 SDK mock —— 打到我写的一个严格 Anthropic 协议服务端
端到端 PR head 打包出的 dist/cli.js,对同一个服务端跑完整工具往返

严格服务端是让 live 这层有意义的关键:thinking 的签名是对整个 assistant turn 的规范化序列化(thinking 文本 + 每个 tool_use 的 id/name/input + text)做 HMAC 生成,并在下一次请求时重新校验。因此移除一个兄弟 tool_use 后再回放同一个 thinking 块,失败的结构性原因与 api.anthropic.com 完全相同,报错字符串也相同。它同时校验角色交替、空 content 数组、tool_use/tool_result 配对、签名缺失,以及手动模式下"最终 assistant turn 含 tool_use 时必须以 thinking 开头"的规则。

1. 上一轮的发现确已修复 —— 是验证过的,不是假定的

同一个探针,分别跑在 5d35aeaf1a36f6 上(见上方第三张图):

发现 5d35aea f1a36f6
S2 —— 部分孤儿缺口 形如 [thinking, tool_use A, tool_use B] 且只有 B 是孤儿时,仍会级联移除 thinking,尽管 A 还要上线 → 用签名 400 换来"最终 turn 必须以 thinking 开头"的 400 thinking 保留;只有该 turn 无任何 tool_use 幸存时级联才触发 ✅
pruneUntrustworthyThinking 假阳性 一个从未携带 tool_use、签名从未失效的纯 thinking turn,仅因为被更新的 assistant turn 挤掉"最新"位置,就在下一次请求被重新标记为 text(破坏缓存断点,在只信任最后一轮的模型上白费 token) 原样保留,逐字节一致 ✅
执行顺序隐患 处于活跃工具循环非最新步骤上、文本为空且未签名的 thinking 块被悄悄吞掉 —— dropUnsignedThinkingFromAssistantMessages 本就是为在此处主动报错而存在的,却再也识别不出该块 正确抛出代理错误 ✅

第三点值得单独说:修复方式不只是删掉那个有问题的启发式。同一个隐患也适用于被提取出来的空文本保护逻辑,而你在自己后续的一轮 review 中发现了这点,并把该保护逻辑移到了快速失败检查之后。这是正确的处理,也正是我本来会要求的改法。

2. 行为矩阵 —— merge-base vs head

9 个场景中 3 个发生变化,其余 6 个作为护栏被钉住(见上方第一张图)。

# 场景 结论
S1 [thinking(已签名), tool_use A],A 为孤儿 变化 —— base 留下签名已失效的 thinking;head 两者一并移除并合并前后 user turn
S2 [thinking, tool_use A, tool_use B],只有 B 是孤儿 不变 —— thinking 正确存活
S3 级联把该 turn 完全清空 变化 —— 消息被丢弃,相邻 user turn 合并(不会出现双 user 的 400)
S4 非最新 turn 上文本为空的 thinking,原生 Anthropic 变化 —— 被丢弃;下一 turn 上真实的已签名 thinking 不受影响
S5 同样的块出现在最新 turn 上 不变 —— 豁免,这是必须的
S6 从未有过 tool_use 的纯 thinking turn 不变 —— 被移除启发式的假阳性已消失
S7 DeepSeek injectThinkingOnToolUseTurns 占位块 不变 —— 每个 tool-use turn 上的 {thinking:'', signature:''} 都保留
S8 文本为空未签名的 thinking,活跃工具循环,代理 不变 —— 仍然抛错
S9 同上,但工具循环已完成,代理 不变 —— 由已有的 pass 安全移除

S7 比看上去更重要。dropEmptyTextThinkingBlocks!injectThinkingOnToolUseTurns 为门控,而 DeepSeek 的占位块字面上就是一个文本为空的 thinking 块 —— 没有这个门控,这个保护逻辑会剥掉 DeepSeek API 恰恰要求的那个块。该门控存在,并已在 wire 上验证。

S4 的门控同样值得明说,因为这正是这个保护逻辑不冗余的原因:对原生 api.anthropic.com base URL,dropUnsignedAssistantThinking关闭的(anthropicContentGenerator.ts 中的 !isAnthropicNativeBaseUrl(...))。一个 redacted_thinking 块经 Part 转换往返后会变成 {type:'thinking', thinking:''} 且无签名 —— 按那个 pass 自己的定义就是未签名,但那个 pass 此时并不运行。所以在原生路径上没有别的东西会移除它,只有这个保护逻辑会。

3. 真实 socket 上的 live A/B

第一轮是真实流式请求;服务端返回一个已签名的 thinking 块加一个兄弟 tool_use,客户端通过真实的 Anthropic→Gemini 转换记录下来,合并方式与 GeminiChat.processStreamResponse 完全一致。随后工具调用被取消,因此第二轮回放的 turn 中 tool_use 是真正的孤儿。

4. 端到端 CLI 会话

PR head 打包出的 dist/cli.js 对严格服务端完成了完整的工具往返。4 次请求全部 200,每个已签名的 thinking 块跨 turn 逐字节原样回放。常规路径无回归。

5. 构建、测试、测试有效性

converter.test.ts 97/97(与你的审阅者测试计划一致),整个 anthropicContentGenerator/ 套件 222/222,typecheck 与 eslint 干净,bundle 构建通过。

关于测试有效性:我把 head 的测试文件放到 merge-base 实现上重放。7 个新测试中有 3 个在那里失败 —— cascade-strips a signed thinking block…drops the whole message and merges surrounding user turns…、以及 drops an empty redacted_thinking-derived turn entirely。另外 4 个在 merge-base 上同样通过,因为它们钉住的是 base 本来就有的行为:它们是针对上一版回归(部分孤儿、最新 turn 豁免、执行顺序、假阳性)的护栏,而不是对新行为的测试。对这个 PR 来说这是正确的形态 —— 这些护栏正是我会要求补的 —— 但仍值得说明,以免有人把"7 个新测试"读成"7 个新行为"。

两点说明,均不阻塞

同 turn 级联的可达性。 我去找了今天转换器的 cleanOrphanedToolCalls 究竟在哪条路径上真的会看到孤儿 tool_use,在主客户端上没有找到。repairOrphanedToolUseTurns 会跑两次 —— 一次在会话加载时(client.ts),一次在 sendMessageStream 内部、 user content 入栈之后、压缩落定之后 —— 而且它遍历整个历史而非仅尾部,为每个悬空 tool_use 合成 functionResponseslimCompactionInput 是严格的 1:1 part 映射,不会遗落任何东西。子代理同样走 GeminiChat.sendMessageStream。我也尝试有机地制造该状态:真实 CLI 会话运行一个长时 shell 工具,中途 SIGINT,再用 --continue 恢复 —— 取消路径会合成一个 tool_result,因此不会出现孤儿。

我提这一点,是因为这正是(正确地)否决 PATCH-B 的同一个论据,它值得一个回答,而不是被悄悄地区别对待。回答是:这是两类不同的代码。PATCH-B 是一个推测性的跨 turn 启发式,试图从一个含混的当前状态推断一个不可观测的过去状态。而这个改动是单个函数的局部自洽不变式:只要 cleanOrphanedToolCalls 从某个 turn 中移除了最后一个 tool_use —— 无论它因何触发、现在还是将来 —— 把 thinking 兄弟块留在原地就是这个 pass 自己制造的一个潜在 400。它只有约 15 行,没有假阳性模式(只在本函数刚刚执行的移除上触发),并且封堵了在 #8159/#8163 中已经通过另一个孤儿清理实现咬到我们的那一类 bug。即便今天在主路径上可达性为零,也值得保留。

残留边界:最新 turn 的豁免。 出现在最新 assistant turn 上、由 redacted_thinking 衍生的空文本块仍会上线(S5),原生 Anthropic 会拒绝它。这是既有问题,在 merge-base 上完全一样,而且在这个转换器内部无解:当该 turn 存在 tool_use 时,移除该块只会把"必须有签名"的 400 换成"必须以 thinking 块开头"的 400。真正的修复在上游 —— 不要在 convertAnthropicResponseToGeminiPart 往返中丢掉 redacted_thinking 的不透明 data(目前它变成 {text:'', thought:true}data 被直接丢弃)。这超出本 PR 范围;值得开一个后续 issue。

小问题,不阻塞:dropEmptyTextThinkingBlocks{role, content} 重建消息,而相邻的其他 pass 用的是 {...message, content}MessageParam 目前没有其他字段所以无害,但用展开会更一致。

结论

同意合并。 这已经是该改动收窄后可证明的版本:它只移除会导致已确认 400 的内容,或在任何上下文下都无条件无效的内容。我上一轮的每一条顾虑都在旧版上复现、在这一版上解决,DeepSeek 与活跃工具循环两个边界也在 wire 上成立。感谢你认真对待可复现性这个问题,而不是拿契约来争辩 —— 依据 review 证据把自己的改动收窄到可证明的核心,是更难也更正确的选择。

wenshao
wenshao previously approved these changes Aug 1, 2026
@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because the pre-execution risk screen refused this sponsored run: the model risk screen failed or returned an unparseable answer. A maintainer who has reviewed the diff can run the verification manually in a disposable environment.

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:the pre-execution risk screen refused this sponsored run: the model risk screen failed or returned an unparseable answer. A maintainer who has reviewed the diff can run the verification manually in a disposable environment。

Qwen Code · sandboxed verification

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Root cause

main commit 40efe41ce (PR #8163, "fix(anthropic): dedup duplicate tool_result blocks sharing a tool_use_id") collided with this PR. Both changed the per-message filter loop in cleanOrphanedToolCalls (packages/core/src/core/anthropicContentGenerator/converter.ts) and both inserted a new setup line at the exact same spot — immediately before const filtered = blocks.filter(...). converter.test.ts auto-merged cleanly.

Textual, not semantic

Adjacent insertions of independent features; the filter body auto-merged and already referenced both variables, so the fix was keeping both declarations:

let toolUseRemoved = false;                     // PR #8166: cascade-strip tracking
const keepToolResult = makeToolResultDeduper(); // main #8163: tool_result dedup

const filtered = blocks.filter((b) => {
  // tool_use:  if (!keep) toolUseRemoved = true;   (PR)
  // tool_result: return keepToolResult(id);        (main)
});

makeToolResultDeduper arrived with main and is defined earlier in the merged file.

What is load-bearing

What I could not verify

No build/typecheck/tests run here. Both sides also added tests to converter.test.ts; the auto-merge kept both sets, and neither asserts on the other's behavior. The combined case (a turn with both a duplicate tool_result and an orphaned tool_use) is covered by neither side's tests — PR CI is the authority.

中文说明

根因main 上的提交 40efe41ce(PR #8163,按 tool_use_id 去重重复的 tool_result 块)与本 PR 冲突。两者都修改了 converter.tscleanOrphanedToolCalls 的过滤循环,并在同一位置(const filtered = blocks.filter(...) 正上方)各插入了一行初始化代码。converter.test.ts 自动合并成功。

文本冲突,非语义冲突:两个功能相互独立,git 已自动合并过滤回调主体,其中同时引用了两个变量,因此解决方式是保留两行声明:let toolUseRemoved = false;(本 PR 的级联清理跟踪)与 const keepToolResult = makeToolResultDeduper();(main 的去重器)。makeToolResultDeduper 随 main 合入,定义在文件前部。

关键约束:有效块集合(validToolUseBlocks / validToolResultBlocks)在过滤前的预扫描中构建。去重只丢弃输出中的 tool_result 块;thinking 同级块的级联清理仅在 tool_use 被移除且无幸存时触发。因此 #8163 的去重永远不会触发 #8166 的级联,反之亦然。若未来改动把去重移入预扫描、或在 tool_result 被丢弃时设置 toolUseRemoved,将破坏这一隔离。mergeConsecutiveUserMessages 中合并消息后重新应用的新去重器(main 的第二处调用)已干净合入,未改动。

未能验证:此处未运行构建/类型检查/测试。双方都向 converter.test.ts 添加了测试,自动合并保留了全部;但"同一轮中同时存在重复 tool_result 与孤儿 tool_use"的组合场景双方测试均未覆盖,以 PR CI 为准。

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has no review of its own on 44fe5d76b486bfb4450313c3b2defa9642b7c7e9. If this re-run was meant to approve, it did not — an approval left by another account is a separate vote and does not count as the bot's own.

The stage comments above were updated with the latest result. View workflow run.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because the pre-execution risk screen refused this sponsored run: the model risk screen failed or returned an unparseable answer. A maintainer who has reviewed the diff can run the verification manually in a disposable environment.

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:the pre-execution risk screen refused this sponsored run: the model risk screen failed or returned an unparseable answer. A maintainer who has reviewed the diff can run the verification manually in a disposable environment。

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 — CI landed green after the 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.

⚠️ Downgraded from Approve to Comment: CI failing: authorize, ack-review-request, review-pr, Post Coverage Comment, delay-automatic-review, resolve-pr, Integration Tests (CLI, No Sandbox), Test (windows-latest, Node 22.x), Test (macos-latest, Node 22.x), precheck-pr / precheck. Reviewed.

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

@wenshao
wenshao added this pull request to the merge queue Aug 1, 2026
Merged via the queue into QwenLM:main with commit c1539df Aug 1, 2026
64 of 78 checks passed
@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.
@QwenLM QwenLM deleted a comment Aug 6, 2026
@QwenLM QwenLM deleted a comment Aug 6, 2026
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: stale thinking signatures not pruned on historical turns after a sibling tool_use is removed

4 participants