Skip to content

fix(core): preserve every reasoning episode's signature during history consolidation - #8260

Open
netbrah wants to merge 17 commits into
QwenLM:mainfrom
netbrah:fix/geminichat-thought-consolidation
Open

fix(core): preserve every reasoning episode's signature during history consolidation#8260
netbrah wants to merge 17 commits into
QwenLM:mainfrom
netbrah:fix/geminichat-thought-consolidation

Conversation

@netbrah

@netbrah netbrah commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What this PR does

geminiChat.ts's turn-consolidation step merged every thought-flagged part in a model turn into a single blob and kept only the first thoughtSignature it saw. A turn with more than one distinct reasoning episode — one reasoning span per parallel tool call, on both Anthropic interleaved thinking and OpenAI Responses reasoning items — silently lost every signature after the first, and hoisted the merged blob to the front of the turn regardless of where the episodes actually occurred relative to the tool calls.

This replaces the merge-all/keep-first-signature pass with a single-pass algorithm that:

  1. Closes ("flushes") the current reasoning episode when a non-thought part appears, or when a thought part carries fresh text while the open episode already has both accumulated text and a signature — the boundary between two genuinely distinct episodes.
  2. Concatenates (not "keeps only the first") text and signature fragments within an open episode, so a signature split across multiple stream chunks is reassembled correctly instead of truncated.

Each episode is now preserved as its own Part in its original position, matching how other TypeScript/Rust agent harnesses represent multi-episode reasoning (see Evidence below).

Two related bugs surfaced while implementing this and are fixed in the same PR, since both would otherwise undo the primary fix in specific cases:

  • The XML-tool-call-recovery fallback (Model outputs XML-style tool calls as plain text instead of structured function calls in long sessions #8003) identified "text parts to consume and replace with remainingText" via a bare .text !== undefined check. Since a reasoning episode Part always has a .text field (even '' for a signature-only episode), this check also matched reasoning episodes — silently deleting the turn's reasoning text and signature whenever XML recovery fired on a turn that also carried one. Fixed to use the existing isValidNonThoughtTextPart predicate.
  • mergeConsecutiveAssistantMessages in anthropicContentGenerator/converter.ts unconditionally hoisted all thinking blocks to the front when merging two adjacent assistant messages, which would silently undo this fix's chronological ordering the moment two assistant messages needed merging. Since interleaved-thinking-2025-05-14 is unconditionally enabled whenever thinking is set, thinking blocks no longer need to lead; changed to straight concatenation.

Fixes #8258.

Anthropic converter changes (same PR, previously undescribed)

Roughly half this diff lives in anthropicContentGenerator/. It is not incidental — the geminiChat.ts fix is what makes these shapes reachable, so they ship together:

  • mergeConsecutiveAssistantMessages: hoist-all-thinking to straight concatenation. Covered above.

  • New ensureLeadingThinkingOnToolUseAssistantMessages pass (gated on the outgoing request's real thinking.type === 'enabled'). Because reasoning is no longer hoisted to parts[0], an ordinary "say a line, then think, then call a tool" turn now converts to [text, thinking, tool_use], which manual-mode extended thinking rejects. This pass moves only the first contiguous thinking run to the front, so the interleaving this PR is about is preserved.

    It applies to every assistant message carrying a tool_use, not just the latest one. Scoping it to the latest message (an earlier revision of this PR) was wrong twice over: An error occurred when calling the DeepSeek v4 Pro model. #3786 describes the rejection against a prior assistant turn, so a turn repaired while it was current reverts to the bad shape on the next request and fails one turn later; and making a turn's serialization depend on its position in history means the same turn goes out two different ways on consecutive requests, which — since addCacheControlToMessages anchors on the last user message — rewrites the cached prefix and forces a full prompt-cache re-read every turn. Pinned by the serializes a turn identically whether or not a later turn follows it test.

  • stripTrailingAssistantPrefill moved earlier in the pipeline, ahead of dropEmptyTextThinkingBlocks / the second stripAssistantThinking / mergeConsecutiveUserMessages, so that dropEmptyTextThinkingBlocks's one-shot "which message is latest" computation reflects the array's true final shape. The "history ends with a user message" invariant survives the reorder (dropEmptyTextThinkingBlocks skips latestAssistantIdx; mergeConsecutiveUserMessages cannot change the terminal role).

  • New dropDanglingUnsignedTrailingThought helper, applied at three call sites: end of per-stream consolidation, inside XML tool-call recovery immediately before the recovered functionCall parts are appended, and on a truncated turn's own parts before coalesceRecoveryPairs. An unsigned trailing episode is a reasoning span whose terminating signature never arrived; pairing it with a tool_use permanently wedges the session once the tool result returns.

Evidence / prior art

Checked how other agent harnesses represent multi-episode reasoning in a single turn — none merge separate episodes into one object:

  • OpenAI Codex (codex-rs/protocol/src/models.rs:314-324) — ResponseItem::Reasoning is a distinct history item per reasoning episode.
  • OpenCode (anomalyco/opencode, packages/opencode/src/session/message-v2.ts) — each reasoning span is its own { type: "reasoning", text, metadata } part; the file has an explicit comment describing this exact multi-episode-per-turn shape for Anthropic adaptive thinking.
  • Vercel AI SDK (vercel/ai) — LanguageModelV2Reasoning is one variant in a LanguageModelV2Content[] array; multiple reasoning spans are multiple array entries.

Reviewer Test Plan

How to verify

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

Expected: all tests pass (369 across both files). Key scenarios covered:

  • A turn with reasoning episodes interleaved with parallel tool calls preserves each episode's own signature.
  • Back-to-back reasoning episodes with no intervening tool call still split correctly once the first has a signature.
  • A signature arriving split across multiple stream chunks concatenates correctly instead of truncating.
  • A reasoning episode co-occurring with an XML-tool-call-recovery turn (Model outputs XML-style tool calls as plain text instead of structured function calls in long sessions #8003) survives in history with its text and signature intact.
  • mergeConsecutiveAssistantMessages preserves chronological order across a merge instead of hoisting thinking blocks to the front.

Known, documented residual limitations (not fixed here)

All three are called out in code comments and pinned by tests that assert the current behavior, so revisiting any of them turns a test red on purpose.

  1. Two unsigned back-to-back thought parts merge into one episode. The boundary heuristic has no signal to split on without a signature to test. Consistent with both wires' invariant that every episode ends in a signature-only chunk; not reachable via Anthropic interleaved thinking or OpenAI Responses reasoning items as implemented.
  2. Two adjacent text-less signed thought parts concatenate their signatures into one {text:'', thought:true, thoughtSignature:'AB'} part that is valid for neither block. This is the mirror image of (1) and is not disambiguable at this layer — intra-episode signature fragmentation is exactly what the concatenation exists to serve. Prior behavior dropped the signature entirely in this shape, so it trades a lossy result for a corrupt-on-replay one (a bad signature 400s where a missing one merely degrades). Unreachable on the Anthropic wire, where thinking blocks always carry text; reachable on the OpenAI Responses wire when reasoning summaries are disabled and only encrypted_content is returned, so flagging it explicitly for feat(core): add OpenAI Responses API content generator #8169 rather than letting that PR inherit it silently.
  3. dropDanglingUnsignedTrailingThought has an accepted false positive. A non-signing provider (DeepSeek) truncated mid-reasoning after a tool call produces the same array shape as a truncated signing-provider episode, and its trailing reasoning is dropped from both history and the JSONL record. Gating the pop on "this turn carries at least one signature" fixes this call site but is wrong at the recovery-coalescing site, where a truncated turn legitimately has no signature anywhere yet. Losing a trailing reasoning fragment for a provider that never validates signatures is the cheaper failure than permanently wedging a session that does. Separately, the coalescing call site mutates in-memory history only — recordAssistantTurn has already written the turn to disk, so --resume can rehydrate the dangling episode. That is inherited drift in the recovery-coalescing mechanism as a whole (the dropped recovery pair is likewise already on disk); closing it belongs at the persistence layer.

Risk & Scope

  • Touches shared history-consolidation code used by every wire (Gemini, Anthropic, OpenAI, OpenAI Responses), so the change is scoped tightly to the thought-part consolidation loop and the two related call sites it affects; no unrelated refactors.
  • Independent of feat(core): add OpenAI Responses API content generator #8169 (OpenAI Responses API) — this PR does not depend on or modify any file introduced by that PR.
  • Breaking changes / migration notes: one deliberate, previously-undeclared change. recordAssistantTurn now records every consolidated part verbatim, where the old code rebuilt the record as [thought?, {text: contentText}?, ...functionCalls]. Because redactStructuredOutputArgsForRecording returns null for every non-functionCall part, media parts were previously never recorded; inlineData/fileData now land in the session JSONL. That is the right call for --resume fidelity, but it does mean model-produced base64 media persists on disk, so it is called out explicitly rather than shipped as a silent side effect.
  • Single-episode turns (the common case today) produce byte-identical output to the pre-fix behavior — confirmed by the existing test suite's 0 changed assertions. The one intentionally changed existing assertion is ensureLeadingAssistantThinking's scope (latest-only → every tool_use turn), described above.

Linked Issues

Fixes #8258

中文说明

本 PR 做了什么

geminiChat.ts 的轮次整合逻辑会把一轮对话中所有带 thought 标记的部件合并成一个整体,并且只保留第一个出现的 thoughtSignature。当一轮对话包含多个独立的推理片段时——例如在 Anthropic 交替思考(interleaved thinking)或 OpenAI Responses 推理项中,每次并行工具调用都会对应一次推理——第一个之后的所有签名都会被静默丢弃,并且合并后的整体会被提前挪到该轮次的最前面,而不管这些推理片段相对于工具调用的实际发生顺序。

本 PR 用一套单遍算法替换了原来的"全部合并、只保留第一个签名"的逻辑:

  1. 当出现一个非 thought 部件时,或者当一个 thought 部件带来新的文本、而当前打开的片段已经同时具备累积文本和签名时,就结束(flush)当前的推理片段——这正是两个真正不同片段之间的边界。
  2. 在一个打开的片段内部,文本和签名片段会被拼接(而不是"只保留第一个见到的"),这样即使签名在多个流式数据块之间被拆分,也能被正确地重新组装,而不会被截断。

现在每个推理片段都会被保留为它自己的 Part,并保持在原始位置上,这与其他 TypeScript/Rust agent 框架表示多片段推理的方式一致(详见下方"证据"部分)。

在实现这个修复的过程中,还发现并一并修复了两个相关的 bug,因为如果不修复,它们会在特定情况下悄悄抵消这次主要修复:

  • XML 工具调用恢复兜底逻辑(Model outputs XML-style tool calls as plain text instead of structured function calls in long sessions #8003)此前是用一个简单的 .text !== undefined 判断来识别"需要被消费并替换为 remainingText 的文本部件"。由于一个推理片段的 Part 总是带有 .text 字段(即使是纯签名、没有文本内容的片段,其 .text 也会是空字符串),这个判断也会误命中推理片段——只要 XML 恢复逻辑在同一轮次里恰好和一个推理片段同时出现,就会把该推理片段的文本签名一并静默删除。修复方式是改用已有的 isValidNonThoughtTextPart 判断函数。
  • anthropicContentGenerator/converter.ts 中的 mergeConsecutiveAssistantMessages 在合并两条相邻的 assistant 消息时,会无条件地把所有 thinking 区块提到最前面,这会在需要合并两条 assistant 消息的那一刻,悄悄抵消本次修复所建立的按时间顺序排列。由于只要设置了 thinkinginterleaved-thinking-2025-05-14 就会被无条件启用,thinking 区块已经不需要再排在最前面,因此这里改为按原有顺序直接拼接。

Fixes #8258

证据 / 已有实践

我们查看了其他 agent 框架是如何在同一轮次内表示多个推理片段的——没有一个是把不同的片段合并成一个对象:

  • OpenAI Codexcodex-rs/protocol/src/models.rs:314-324)—— ResponseItem::Reasoning 对每一个推理片段都是一条独立的历史记录项。
  • OpenCodeanomalyco/opencodepackages/opencode/src/session/message-v2.ts)—— 每个推理片段都是它自己的 { type: "reasoning", text, metadata } 部件;该文件中还有一段明确的注释描述了 Anthropic 自适应思考下正是这种"一轮多片段"的形态。
  • Vercel AI SDKvercel/ai)—— LanguageModelV2ReasoningLanguageModelV2Content[] 数组中的一种变体;多个推理片段就是数组中的多个条目。

审阅者测试计划

如何验证

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

预期:两个文件中的全部测试通过(合计 369 个)。覆盖的关键场景包括:

  • 一轮对话中,推理片段与并行工具调用交替出现时,每个片段各自的签名都能被保留。
  • 没有中间工具调用、背靠背出现的两个推理片段,一旦第一个片段有了签名,仍然能被正确拆分。
  • 签名在多个流式数据块之间被拆分时,能正确拼接而不是被截断。
  • 一个推理片段与触发 XML 工具调用恢复(Model outputs XML-style tool calls as plain text instead of structured function calls in long sessions #8003)的轮次同时出现时,其文本和签名都能完整保留在历史记录中。
  • mergeConsecutiveAssistantMessages 在合并时保持按时间顺序排列,而不是把 thinking 区块提到最前面。

已知且已记录的残留限制(本 PR 未修复)

没有任何签名、且中间没有任何非 thought 部件、背靠背出现的两个 thought 部件,仍然会被合并成同一个片段——因为边界判断逻辑在没有签名可供比对的情况下无法识别出这是两个片段。这与两条链路都遵循的"每个片段都以一个纯签名数据块结尾"这一约定是一致的,并且按照目前的实现,这种情况在 Anthropic 交替思考或 OpenAI Responses 推理项中都不会真正出现;代码中已经加了注释说明这一点,以便将来如果某个不合规的代理完全丢弃了签名,方便定位。

风险与范围

  • 涉及的是所有链路(Gemini、Anthropic、OpenAI、OpenAI Responses)共用的历史整合代码,因此改动范围严格限定在 thought 部件的整合循环以及受其影响的两个相关调用点上,没有附带任何无关的重构。
  • feat(core): add OpenAI Responses API content generator #8169(OpenAI Responses API)相互独立——本 PR 不依赖、也不修改该 PR 引入的任何文件。
  • 破坏性改动 / 迁移说明:有一处刻意为之、此前未声明的改动。 recordAssistantTurn 现在会逐字记录所有整合后的部件,因此 inlineData/fileData 会写入会话 JSONL(旧逻辑对非 functionCall 部件返回 null,媒体部件从不落盘)。这对 --resume 的保真度是正确的取舍,但确实意味着模型产出的 base64 媒体会长期留在磁盘上,故在此显式说明。另外,ensureLeadingAssistantThinking 的作用范围已从「仅最后一条 assistant 消息」扩大到「每一条带 tool_use 的 assistant 消息」—— An error occurred when calling the DeepSeek v4 Pro model. #3786 针对的是先前的 assistant 轮次,且按位置归一化会导致同一轮次在相邻两次请求中序列化结果不同,从而每轮都打断 prompt cache 前缀。其余:单一片段的轮次(目前最常见的情况)的输出与修复前完全一致,字节级不变——现有测试套件中没有任何一条既有断言被修改,这一点可以印证。

关联 Issue

Fixes #8258

…y consolidation

geminiChat.ts's turn-consolidation step merged every thought-flagged
part in a turn into a single blob and kept only the first
thoughtSignature it saw. A turn with more than one distinct reasoning
episode -- e.g. one reasoning span per parallel tool call on Anthropic
interleaved thinking or OpenAI Responses reasoning items -- silently
lost every signature after the first, and hoisted the merged blob to
the front of the turn regardless of where the episodes actually
occurred relative to the tool calls.

Replace the merge-all/keep-first-signature pass with a single-pass
algorithm that treats a text-less, signature-only chunk as the natural
end of an episode on both wires, closes ("flushes") the current
episode when a non-thought part appears or when a thought part carries
fresh text while the open episode already has both text and a
signature, and concatenates (not "keeps only the first") text and
signature fragments within an open episode so a signature split across
multiple stream chunks is reassembled correctly. Each episode is now
preserved as its own history Part in its original position.

The XML-tool-call-recovery fallback (QwenLM#8003) had to be updated to match:
it previously identified "text parts to remove and replace with
remainingText" via a bare `.text !== undefined` check, which also
matched a reasoning episode Part (flushThoughtEpisode always sets
`episodePart.text`, even '' for a signature-only episode) -- silently
deleting the turn's reasoning episode, text and signature both,
whenever XML recovery fired on a turn that also carried one. Switched
to isValidNonThoughtTextPart, matching this path's actual intent.

mergeConsecutiveAssistantMessages in anthropicContentGenerator's
converter.ts had a related bug: it unconditionally hoisted all
thinking blocks to the front when merging two adjacent assistant
messages, which would silently undo the primary fix's chronological
ordering the moment two assistant messages needed merging. Since
interleaved-thinking-2025-05-14 is unconditionally enabled, thinking
blocks no longer need to lead; changed to straight concatenation.

Fixes QwenLM#8258.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 31, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run at @wenshao's request, following his end-to-end verification. Gate re-checked at the current head.

Template looks good ✓

Problem: observed bug, now with wire-level evidence. #8258 surfaced in code review on #8169 with the root cause confirmed in source, and the maintainer's local harness (real CLI against a mock Anthropic endpoint) showed main does worse than drop the second signature — it emits one block carrying episode one's signature over both episodes' concatenated text, i.e. a mis-signed block. Observed, not theoretical.

Direction: aligned. Per-episode signature preservation is required for Anthropic interleaved thinking and OpenAI Responses replay validity. Claude Code's CHANGELOG ships a fix in the same problem area ("Fixed sessions getting stuck after ... stale thinking-block signatures in history").

Size: 589 production lines (geminiChat.ts +348/−84, converter.ts +130/−21, anthropicContentGenerator.ts +6/−0) and 2,519 test lines (+2,356/−163). Production grew from 225 lines at first triage past the 500 mark via review-round fixes (dangling-episode guards, predicate unification), so per the two-tier rule this is flagged for maintainer awareness — noted here, with the consequence stated in Stage 3. Not blocking on size alone.

Approach: still the minimal cohesive set. The primary consolidation change and its companions (XML-recovery predicate, converter hoist removal, dangling-episode guards) each close a defect that would otherwise undo the primary fix; splitting them would ship broken intermediate states. No drive-by changes.

Risk: ⚠️ geminiChat.ts matches this repo's high-risk path set (correlated with post-merge reverts), so full Stage 2 enrichment and CI evidence apply. The sandboxed /verify run is already in flight for this head (see Stage 2).

Moving on to code review. 🔍

中文说明

@wenshao 的要求,在其端到端验证之后重新运行。在当前 head 重新过门禁。

模板完整 ✓

问题:已观测到的 bug,现有线级证据。#8258#8169 的代码审查中被发现,根因已在源码中确认;维护者的本地验证环境(真实 CLI 对模拟 Anthropic 端点)进一步表明 main 的问题比"丢失第二个签名"更严重——它发出的块在两个片段的拼接文本上带着第一个片段的签名,即签名错误的块。已观测而非理论。

方向:一致。按片段保留签名对 Anthropic 交替思考与 OpenAI Responses 的重放有效性是必需的。Claude Code 的 CHANGELOG 发布过同一问题领域的修复("修复了……历史中残留的思考块签名导致会话卡住")。

规模:589 行生产代码geminiChat.ts +348/−84,converter.ts +130/−21,anthropicContentGenerator.ts +6/−0)与 2,519 行测试(+2,356/−163)。生产代码从首次分诊时的 225 行经审查轮次修复(悬空片段守卫、谓词统一)增长到超过 500 行,按两级规则标记给维护者知悉——在此标记,后果见 Stage 3。不单以规模阻塞。

方案:仍是最小内聚集合。主整合改动与伴随修复(XML 恢复谓词、converter 提前逻辑移除、悬空片段守卫)各自关闭一个会抵消主修复的缺陷;拆分会发布损坏的中间状态。无夹带改动。

风险:⚠️ geminiChat.ts 命中本仓库的高风险路径集(与合并后回滚相关),适用完整的 Stage 2 增强与 CI 证据。针对当前 head 的沙箱 /verify 运行已在进行中(见 Stage 2)。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review (at 6b3e68adc5, seventh-round head)

Independent proposal vs. the diff: I'd have done the same thing — replace merge-all/keep-first-signature with a single-pass episode tracker that flushes on a non-thought part or on fresh text after a signed episode, concatenate signature fragments, drop the converter's thinking hoist, and repair manual-mode's leading-thinking requirement uniformly across history so serialization stays position-independent. The PR matches this; I found no simpler path it missed. What it adds beyond my proposal — the dangling-episode guards and the predicate unification — each answers a concrete defect surfaced in the review rounds, not speculation.

What this pass verified, beyond the prior rounds:

  • The consolidation loop's boundary logic is correct, and the two documented known limitations are real and honestly bounded: back-to-back unsigned episodes merge (no signature to test), and two text-less signed episodes concatenate into one signature valid for neither — unreachable on the Anthropic wire, reachable only via the feat(core): add OpenAI Responses API content generator #8169 Responses shape with summaries disabled, and flagged there.
  • dropDanglingUnsignedTrailingThought's trailing-only scope is the right call: truncation can only strand the last episode, and non-signing providers (DeepSeek) legitimately carry unsigned thoughts mid-array. All four call sites are placement-justified; the XML-recovery one correctly captures trailing-ness before its removal loop (which would otherwise manufacture a trailing position), and the Math.min(insertAt, length) clamp covers the shrink.
  • isVisibleTextPart now makes contentText and the XML-removal loop the exact same set. I verified against main that isValidNonThoughtTextPart excludes thoughtSignature-bearing parts, so the deliberately looser predicate is the right middle: the stricter one would leak raw XML into durable history, the bare .text !== undefined one would delete reasoning episodes.
  • Converter: straight concatenation in mergeConsecutiveAssistantMessages is sound because interleaved-thinking-2025-05-14 is unconditionally enabled whenever thinking is set, and ensureLeadingThinkingOnToolUseAssistantMessages moves only the first contiguous thinking run, only on tool_use-bearing messages, only in manual mode — keeping the cached prefix stable across turns (wenshao confirmed byte-identical manual-mode output vs main, and prompt-cache prefix stability, on the wire).
  • Recording: redactStructuredOutputArgsForRecording returns null only for parts without a functionCall (checked against main), so the non-null assertion is valid; inlineData/fileData now reaching the session JSONL is declared in the PR body.

Findings: no critical blockers at this head. One accuracy nit for the record: the PR body says no existing assertion was modified, but one test's assertions were inverted — the converter merge-order test now expects [thinking, text, thinking, tool_use] instead of [thinking, thinking, text, tool_use]. That's the intentional behavior change itself, pinned by the rewritten test, and belongs with the maintainer's existing stale-count/doc nits. Non-blocking.

The consolidation → wire flow this PR reshapes:

sequenceDiagram
    participant P1 as Stream chunks
    participant P2 as Consolidation loop
    participant P3 as Dangling-episode guard
    participant P4 as History and JSONL
    participant P5 as Converter pipeline
    participant P6 as Anthropic wire
    P1->>P2: thought, text and toolCall parts in stream order
    P2->>P2: flush episode on non-thought, or on new text after a signature
    P2->>P3: each episode as its own Part, original position
    P3->>P4: drop unsigned trailing episode when a tool call is present
    P4->>P5: next request rebuilds messages from history
    P5->>P6: manual mode moves the first thinking run to the front
Loading
Files changed (6)
File What changed
packages/core/src/core/geminiChat.ts episode-tracking consolidation replacing merge-all/keep-first; shared visible-text predicate; dangling-episode guard at four sites; verbatim recording
packages/core/src/core/geminiChat.test.ts 26 new tests — multi-episode interleaving, split signatures, back-to-back episodes, XML-recovery interaction, recovery coalescing
packages/core/src/core/anthropicContentGenerator/converter.ts straight concatenation in assistant merge; new leading-thinking repair for manual mode; prefill strip moved ahead of the empty-text pass
packages/core/src/core/anthropicContentGenerator/converter.test.ts 12 new tests — merge ordering, leading-thinking scoping, position-independent serialization
packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts gates the new repair on the outgoing request's real manual-mode flag
packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts 3 end-to-end generator tests — manual explicit-budget, manual effort-ladder, adaptive stays chronological

Testing

Unattended CI run — no PR code executed locally. Evidence is the PR's own CI at the reviewed commit, quoted below, plus the maintainer's harness write-up (attributed) and the in-flight sandboxed run.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Secret scan (TruffleHog) ✅ success
Dependency CVE audit ✅ success
precheck-pr / precheck ✅ success

All checks on 6b3e68adc5 are complete; none failed. Test (macos/windows) and Integration Tests (CLI) are skipped by design on every PR — those jobs run only in the merge queue, not conditional on ubuntu (correcting this comment's earlier characterization).

Behavioural evidence. The wire-level claim (episodes survive consolidation and replay byte-exact) is not settled by unit CI alone. Two things speak to it here: (1) wenshao's maintainer verification — real CLI against a mock Anthropic endpoint, A/B vs the merge-base, three mutation probes showing the new code is load-bearing, manual-mode output byte-identical to main — posted above and attributed to him, not re-run by this bot; and (2) a sponsored sandboxed /verify run is already in flight for this head (run 32659662886); its A/B report will post to the lifecycle comment. Read that report with the same skepticism as the fork's own CI logs — the code under verification is adversarial input even though the sandbox bounds what it can do.

Not verified by this run: real Anthropic API (mock only, by both harnesses), the OpenAI Responses wire (#8169), and the DeepSeek truncated-reasoning false positive (code-reading only).

中文说明

代码审查(在第七轮 head 6b3e68adc5 上)

独立方案对照: 我的独立方案与 diff 一致——单遍片段追踪器替换全部合并/只留第一个签名,边界时 flush,签名片段拼接,移除 converter 的 thinking 提前,并在全部历史上统一修复手动模式的首位 thinking 要求以保持序列化与位置无关。未发现被遗漏的更简路径。超出我方案的部分(悬空片段守卫、谓词统一)各自回应审查轮次暴露的具体缺陷,而非臆测加固。

本轮另外核实:整合循环的两个已知限制真实且边界诚实(背靠背无签名片段会合并;两个无文本带签名片段会拼成一个对两者都无效的签名——Anthropic 链路不可达,仅 #8169 关闭摘要的 Responses 形状可达,已在那边标记)。dropDanglingUnsignedTrailingThought 只看尾部是正确的:截断只会把最后一个片段悬空,非签名提供方(DeepSeek)中途带无签名 thought 是合法形状;四个调用点的位置都有论证,XML 恢复处在删除循环之前捕获尾部性,Math.min 钳位覆盖收缩。isVisibleTextPart 使 contentText 与 XML 删除循环成为同一集合;已对照 main 核实 isValidNonThoughtTextPart 会排除带 thoughtSignature 的部件,因此刻意放宽的谓词是正确的中间选择。converter 的直接拼接成立(interleaved-thinking-2025-05-14 在设置 thinking 时无条件启用),首位 thinking 修复只移动第一个连续 run、只作用于带 tool_use 的消息、只在手动模式——缓存前缀稳定(wenshao 在线上确认手动模式输出与 main 逐字节一致)。录制侧:已对照 main 核实 redactStructuredOutputArgsForRecording 仅对无 functionCall 的部件返回 null,非空断言成立;inlineData/fileData 落盘 JSONL 已在 PR 正文声明。

结论: 当前 head 无关键阻塞项。一条记录性小疵:PR 正文称"没有修改任何既有断言",但 converter 合并顺序测试的断言被反转([thinking, thinking, text, tool_use][thinking, text, thinking, tool_use])——这是有意的行为变更本身,由重写后的测试固定,与维护者已提的计数/文档小疵同类。不阻塞。

测试

无人值守 CI——未执行 PR 代码。证据为被审提交上 PR 自身 CI 的引用、维护者的验证记录(注明出处)与进行中的沙箱运行。

6b3e68adc5 上所有检查已完成,无失败。macOS/Windows 与集成测试在所有 PR 上按设计跳过——这些任务只在合并队列运行(更正本评论此前的表述)。

行为证据。 线级声明(片段在整合后存活且逐字节重放)不能仅由单测 CI 证实。此处有两项:(1) wenshao 的维护者验证——真实 CLI 对模拟 Anthropic 端点、与 merge-base A/B 对照、三个变异探针证明新代码承重、手动模式输出与 main 逐字节一致——已在上文发布,出自维护者而非本机器人复跑;(2) 针对该 head 的赞助沙箱 /verify 运行已在进行中,其 A/B 报告将发布到生命周期评论。请以与 fork CI 日志相同的怀疑态度阅读该报告。

本次运行未验证:真实 Anthropic API(两套环境均为 mock)、OpenAI Responses 链路(#8169)、DeepSeek 截断推理误报(仅代码阅读)。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review at this head, but the two-tier rule's 500+ production-line flag for fork core PRs caps the bot at defer; this is the policy speaking, not doubt about the code.

Stepping back: this is the strongest shape a fork PR can arrive in. The problem was observed in the wild (#8258), the root cause is confirmed in source, and the failure is actually worse than reported — a mis-signed block, not merely a lost signature. The algorithm is the natural one; I arrived at it independently before reading the diff. Seven review rounds converged — round seven raised only Suggestions, and wenshao's read of the thread is that no standing Critical remains at this head. The maintainer then verified it end-to-end on the wire with mutation probes and found the new code load-bearing and manual mode byte-identical to main. CI is green at 6b3e68adc5. If I were maintaining this in six months I'd thank the author — the comments explain the why of every guard, including the trade-offs each one accepts.

Why not approve, then: the PR now carries 589 production lines in packages/core/src/core/ (it was 225 at first triage; the growth is review-round fixes, which makes it legitimate, but the size fact stands). Under the two-tier core rule, a fork PR at that size gets maintainer awareness instead of the bot's automatic approval — and wenshao's awareness here, while unambiguously given, is exactly what the rule asks a human to own. He has already cast one of the two required approvals; the gate's question is whether the second one should be the bot's on a change this large in this file (geminiChat.ts is this repo's highest-revert-risk path). That is a human call, so I'm making it one explicitly rather than approving.

@wenshao — escalating per the size rule. Your options as I see them: a second human approval (yours already stands at 6b3e68adc5), or an explicit instruction in this thread to approve despite the size policy (a re-triggered /triage will read it as resolving the escalation), or merge via admin. Two housekeeping facts for whichever path you pick: the round-6 CHANGES_REQUESTED review from this bot account (commit 2fe2ee32, superseded by round 7) still pins reviewDecision and is dismissable by a maintainer; and the branch is 311 commits behind main — you validated the merged tree locally (no conflicts, suite green), so an up-to-date push should be clean, and any approval here is pinned to 6b3e68adc5 and dismisses on the push. The sandboxed /verify report will land in the lifecycle comment shortly.

@netbrah — nothing further needed from you on the code; this hold is policy, not findings.

中文说明

置信度:3/5 — 当前 head 上的审查是干净的;但两级规则对 fork 核心 PR 超过 500 行生产代码的标记将机器人限制为"转交"而非自动批准——这是规则在说话,不是对代码有疑虑。

退一步看:这是一个 fork PR 能达到的最强形态。问题在实际中被观测到(#8258),根因在源码中确认,而且实际失败比报告更糟——是签名错误的块,而非仅仅丢失签名。算法是自然的方案;我在读 diff 之前独立得出了同样的方案。七轮审查收敛——第七轮只有建议,维护者对线程的判断是该 head 上不再有未决 Critical。维护者随后在线上做了端到端验证并施加变异探针,确认新代码承重、手动模式与 main 逐字节一致。CI 在 6b3e68adc5 上全绿。六个月后维护这段代码我会感谢作者——每个守卫的注释都解释了"为什么",包括各自接受的取舍。

那为什么不批准:PR 现在携带 589 行生产代码,位于 packages/core/src/core/(首次分诊时是 225 行;增量来自审查轮次修复——这使其正当,但规模事实不变)。按两级核心规则,该规模的 fork PR 获得的是维护者知悉,而不是机器人的自动批准——wenshao 的知悉在这里毫无歧义,但规则要求的正是由来拥有这个决定。他已经投出两个必需批准中的一个;门禁的问题是:在最高回滚风险路径(geminiChat.ts)上这么大的改动,第二个批准是否应该来自机器人。这是人的决定,所以我把它明确交出去,而不是自行批准。

@wenshao —— 按规模规则转交。我看到的路径:第二个人类批准(你的已落在 6b3e68adc5),或在本线程明确指示忽略规模政策批准(重新触发的 /triage 会将其读作升级已解决),或以管理员身份合并。无论哪条路径,两个事务性事实:本机器人账号第六轮的 CHANGES_REQUESTED 审查(提交 2fe2ee32,已被第七轮取代)仍钉住 reviewDecision,维护者可将其驳回;分支落后 main 311 个提交——你已在本地验证过合并树(无冲突、套件全绿),同步推送应该是干净的,且此处的任何批准都钉在 6b3e68adc5 上、推送即失效。沙箱 /verify 报告稍后会落在生命周期评论里。

@netbrah —— 代码层面无需再做任何事;本次转交是政策原因,不是发现问题。

Qwen Code · qwen3.8-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@netbrah

netbrah commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Live wire verification: same-response multi-episode replay confirmed

I ran the built dogfood/reasoning-fidelity branch through a byte-recording reverse proxy against a real OpenAI Responses model (gpt-5.6-sol, high reasoning effort). This produced the exact runtime shape that the consolidation fix targets, not merely multi-turn signature growth.

Qualifying response

One SSE response completed two distinct encrypted reasoning items before its function call. Sanitized response.output_item.done events:

{"output_index":0,"type":"reasoning","id":"rs_040c2d905c1da525016a6d12a1e9108197bbaf5185bd010f22","encrypted_bytes":1464,"encrypted_content_sha256":"cc02d44cb4fdbd13d9f52df07f02ec293561fa63ac42a1a9fd7a234e54c31e7a"}
{"output_index":1,"type":"reasoning","id":"rs_040c2d905c1da525016a6d12a3b08c8197afebe810eb1c207f","encrypted_bytes":1100,"encrypted_content_sha256":"eb592d9b5256e58fb86bd034029823cfa8be61cc7ea54f8efec60f5f038f4bd8"}
{"output_index":2,"type":"function_call","id":"fc_040c2d905c1da525016a6d12a4cd3481978ff7576b94ac7264","call_id":"call_RCmWggzme2vihVKSZ7Ql4UZq","name":"run_shell_command"}

So the model emitted this in one response:

reasoning A + encrypted payload A
reasoning B + encrypted payload B
function_call

This is the PR's back-to-back episode case: a second reasoning episode begins after the first episode is signed, without a tool-call boundary between them.

Immediately following request

After Qwen Code converted and consolidated that response, the next request contained these ordered replay items:

{"input_index":1,"type":"reasoning","id":"rs_040c2d905c1da525016a6d12a1e9108197bbaf5185bd010f22","encrypted_bytes":1464,"encrypted_content_sha256":"cc02d44cb4fdbd13d9f52df07f02ec293561fa63ac42a1a9fd7a234e54c31e7a"}
{"input_index":2,"type":"reasoning","id":"rs_040c2d905c1da525016a6d12a3b08c8197afebe810eb1c207f","encrypted_bytes":1100,"encrypted_content_sha256":"eb592d9b5256e58fb86bd034029823cfa8be61cc7ea54f8efec60f5f038f4bd8"}
{"input_index":3,"type":"function_call","call_id":"call_RCmWggzme2vihVKSZ7Ql4UZq","name":"run_shell_command"}
{"input_index":4,"type":"function_call_output","call_id":"call_RCmWggzme2vihVKSZ7Ql4UZq"}

The verification compared the complete opaque payloads, not truncated display strings:

response reasoning IDs   == next-request reasoning IDs       PASS
response payload lengths == next-request payload lengths     PASS
response SHA-256 list     == next-request SHA-256 list        PASS
response item order       == next-request replay order        PASS
Episode Response bytes Next-request bytes Response SHA-256 Next-request SHA-256 Result
A 1464 1464 cc02d44cb4fdbd13d9f52df07f02ec293561fa63ac42a1a9fd7a234e54c31e7a same exact
B 1100 1100 eb592d9b5256e58fb86bd034029823cfa8be61cc7ea54f8efec60f5f038f4bd8 same exact

This is direct live-wire evidence that both same-response reasoning episodes survived geminiChat.ts history consolidation as separate entries, retained their order relative to the function call, and replayed byte-for-byte. Under the previous merge-all/keep-first behavior, episode B's replay payload would have been lost.

A separate four-user-turn Anthropic capture verified ordinary history accumulation (0,1,1,2,2,3,3,4) with every signature list an ordered byte-exact prefix of the final list, but that is only a replay-plumbing baseline. The Responses capture above is the load-bearing proof for this PR because both encrypted episodes originated in one model response.

I also probed claude-sonnet-5 with adaptive thinking, high effort, and interleaved thinking confirmed on the wire. Sonnet produced separate HTTP responses (thinking -> tool_use per tool round), not multiple thinking blocks inside one response. That is consistent with Anthropic's documented tool loop: interleaved thinking occurs after tool results arrive on subsequent API calls. The OpenAI Responses capture independently reached the exact shared consolidation path fixed here.

The later test-session stop (Model stream ended after a tool result without visible progress) occurred after the qualifying response/request transition and does not affect the byte-exact consolidation proof above.

中文说明

真实链路验证:已确认同一响应中的多个推理片段可无损重放

我将构建后的 dogfood/reasoning-fidelity 分支通过字节级记录代理连接到真实 OpenAI Responses 模型(gpt-5.6-sol,高推理强度)。本次运行生成了该整合修复真正针对的运行时形状,而不仅仅是跨多轮的签名增长。

符合条件的响应

同一个 SSE 响应先完成了两个不同的加密推理 item,然后才输出 function call。上方英文部分的 JSONL 是脱敏后的真实事件,顺序为:

推理片段 A + 加密载荷 A
推理片段 B + 加密载荷 B
function_call

这正是本 PR 的“背靠背推理片段”场景:第一个片段完成签名后,第二个片段开始;两者之间没有工具调用作为天然边界。

紧接着的下一次请求

Qwen Code 转换并整合该响应后,下一次请求按顺序包含:

input[1] = reasoning A
input[2] = reasoning B
input[3] = function_call
input[4] = function_call_output

验证比较的是完整不透明载荷,而不是截断字符串:

响应中的 reasoning ID   == 下一请求中的 reasoning ID       通过
响应中的载荷长度          == 下一请求中的载荷长度              通过
响应中的 SHA-256 列表     == 下一请求中的 SHA-256 列表         通过
响应中的 item 顺序        == 下一请求中的重放顺序               通过
片段 响应字节数 下一请求字节数 SHA-256 结果
A 1464 1464 cc02d44cb4fdbd13d9f52df07f02ec293561fa63ac42a1a9fd7a234e54c31e7a 完全一致
B 1100 1100 eb592d9b5256e58fb86bd034029823cfa8be61cc7ea54f8efec60f5f038f4bd8 完全一致

这是直接的真实链路证据:同一个模型响应中的两个推理片段都以独立条目通过了 geminiChat.ts 历史整合,保留了相对于 function call 的顺序,并逐字节重放。旧的“全部合并、只保留第一个签名”行为会丢失片段 B 的可重放载荷。

另一次 Anthropic 四用户轮次捕获验证了普通历史累积(0,1,1,2,2,3,3,4),且每次请求的签名列表都是最终列表的有序、逐字节一致前缀;但这只是重放链路基线。上面的 Responses 捕获才是本 PR 的关键证据,因为两个加密片段来自同一个模型响应。

我还使用 claude-sonnet-5 验证了 Anthropic 链路,并确认真实请求启用了 adaptive thinking、高 effort 和 interleaved thinking。Sonnet 的实际行为是每个工具轮次使用独立 HTTP 响应(thinking -> tool_use),没有在同一个响应中生成多个 thinking 块。这与 Anthropic 文档描述的工具循环一致:收到工具结果后,模型在后续 API 请求中继续交替思考。OpenAI Responses 的真实捕获已经独立到达本 PR 修复的共享整合路径。

后续测试会话出现的 Model stream ended after a tool result without visible progress 发生在上述合格的响应/请求转换之后,不影响上面的逐字节整合证明。

qqqys
qqqys previously requested changes Jul 31, 2026

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

A blocking manual-thinking regression remains in the converter merge path. The inline finding identifies the deterministic request shape and affected model mode.

Comment thread packages/core/src/core/anthropicContentGenerator/converter.ts
Addresses review feedback on QwenLM#8260: mergeConsecutiveAssistantMessages's
straight concatenation preserves chronological order but can leave a
merged assistant turn's content beginning with text instead of
thinking (e.g. [text A] + [thinking B, tool_use B] -> [text A,
thinking B, tool_use B]). Anthropic's manual (non-adaptive)
extended-thinking contract requires the final assistant turn of a
thinking-enabled request to begin with a thinking block whenever a
tool_use remains in it; adaptive thinking has no such requirement, so
the request would 400 on the follow-up tool-result turn.

Add ensureLeadingAssistantThinking, a converter option gated on the
outgoing request's actual thinking.type === 'enabled' mode (passed
from anthropicContentGenerator.ts). When set, after all merge/cleanup
passes finish, it relocates the most recent assistant message's first
contiguous thinking/redacted_thinking run to the front of its content
array -- and only that run, leaving every other block (including
later thinking blocks and their relative order) untouched. It does
not fabricate a thinking block where none exists, and is a no-op for
adaptive-thinking models (Opus 4.7+, every 5.x) and thinking-off
requests.

Replaces the previous "pins current behavior" regression test (which
documented the residual risk without fixing it) with two assertions:
adaptive/default mode still preserves chronological order, and the
new option produces the required leading-thinking shape. Adds a
generator-level regression test using an explicit-budget (manual)
configuration on claude-opus-4-6, covering the full tool-loop request
shape, the interleaved-thinking beta, and signature preservation.

@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

Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/core/src/core/anthropicContentGenerator/converter.test.ts
@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Code Review — fix(core): preserve every reasoning episode's signature during history consolidation

Reviewed at b7fe21c (base 912f7399). I ran the three touched suites locally — 486/486 pass (geminiChat.test.ts 278, anthropicContentGenerator.test.ts 116, converter.test.ts 92).

Overview

The core change is right and well-argued. Replacing the merge-all/keep-first-signature pass with a single-pass episode tracker is the correct shape: each reasoning episode keeps its own Part, its own signature, and its original position relative to the tool calls it preceded. The mergeConsecutiveAssistantMessages de-hoisting and the isValidNonThoughtTextPart fix in the XML-recovery path are genuinely coupled to it — splitting them would leave intermediate broken states. Comment density and the "known limitation" callout are exemplary; the tests pin the interesting boundaries (interleaved episodes, back-to-back episodes, fragmented signature, signature-only mid-turn episode, OpenAI-Responses-shaped payloads).

Two things need attention before merge, one of them a verified regression.


1. 🔴 Regression: XML recovery now leaves raw <invoke> XML in history for { text, thoughtSignature } parts

geminiChat.ts uses two different predicates for what is supposed to be the same set of parts:

  • contentText (and therefore recovery.remainingText) — part.text && !part.thought
  • the removal loop — isValidNonThoughtTextPart(part), which additionally rejects part.thoughtSignature, inlineData, fileData, functionCall, functionResponse

They are no longer complements. A non-thought text part carrying a thoughtSignature — Gemini's placement for the signature that concludes a reasoning span, and a shape this repo explicitly expects (loggingContentGenerator.ts:976-986 preserves exactly { text, thoughtSignature } with no thought flag) — is counted into contentText but not removed. The result: the original part survives with the raw XML in it, and remainingText is spliced in on top → duplicated prose and <invoke …> leaking into durable history.

Verified with a probe (single chunk, parts [{ text: 'Sure.\n<invoke …>', thoughtSignature: 'gemini-sig' }], then assert no history part contains <invoke):

commit result
912f7399 (base) ✅ passes — old .text !== undefined consumed the part
b7fe21c (this PR) ❌ fails — history is [{text:'Sure.'}, {text:'Sure.\n<invoke …>', thoughtSignature}, {functionCall}]

The existing test retains a short text prefix in history when recovering XML tool calls asserts exactly this invariant; it just doesn't cover the signature-bearing variant.

Suggested fix — one predicate, used in both places:

const isConsumableRecoveryText = (part: Part) =>
  !part.thought && typeof part.text === 'string' && part.text !== '';

…and use it for contentText, for the recomputed contentText, and for textIndices. (Or keep isValidNonThoughtTextPart for both — either way they must agree.) Worth a regression test with thoughtSignature on the plain-text part.

2. 🟡 Undeclared behavior change: session JSONL now embeds inlineData/fileData blobs

The recording rewrite changed more than the reasoning parts. Old code built the record explicitly ([thought?, {text: contentText}?, ...functionCallParts]) — redactStructuredOutputArgsForRecording returned null for every non-functionCall part, so media parts were never recorded. New code records every element of consolidatedHistoryParts.

Verified probe (model turn = [{text:'here is the image'}, {inlineData:{mimeType:'image/png', data:'BASE64BLOB'}}]):

base      PROBE RECORDED [{"text":"here is the image"}]
this PR   PROBE RECORDED [{"text":"here is the image"},{"inlineData":{"mimeType":"image/png","data":"BASE64BLOB"}}]

For image-capable models this writes full base64 payloads into the session JSONL on every turn — unbounded file growth, and model-produced media now sits on disk indefinitely, which reads against the privacy rationale documented right above redactStructuredOutputArgsForRecording. The PR states "Breaking changes / migration notes: none", so this looks unintended rather than a deliberate fidelity improvement. Either filter the recording to text/thought/functionCall parts, or make it an explicit, documented decision.

While you're there: .filter((part): part is NonNullable<typeof part> => part !== null) after part.functionCall ? redact(part) : part is dead — redactStructuredOutputArgsForRecording only returns null when !part.functionCall, which the ternary already excludes.

3. 🟡 Commit 2 (ensureLeadingAssistantThinking) isn't in the PR description

The description only covers the de-hoisting; the second commit partially re-introduces it for manual mode. That's a reasonable escape hatch, but three things:

  • Update the PR body — a reviewer reading only the description will not know this pass exists.
  • What's the evidence? The added test asserts our own converter output against a mocked SDK, so it can't demonstrate that Anthropic actually rejects the straight-concatenated shape. Did you see a live 400 (Expected 'thinking' or 'redacted_thinking', but found 'text') on a manual-budget model? If yes, quoting it in the body would settle it; if it's precautionary, say so.
  • Scope: the doc says "whenever a tool_use remains in it", but the implementation reorders the latest assistant message unconditionally. When that message has no tool_use (e.g. a fresh user turn following a completed assistant turn), the reorder is unnecessary and re-breaks the chronology this PR set out to protect. Gating on blocks.some(b => b.type === 'tool_use') would make it match its own doc.

4. 🟢 Follow-ups / observations (non-blocking)

  • Per-episode .trim() vs. signature validity. flushThoughtEpisode trims each episode's accumulated text. Anthropic's signature is computed over the exact thinking text, so trimming is a replay-validity hazard. It's pre-existing for the single-episode case, but the multi-episode split now applies it at every internal episode boundary too (e.g. "A\n\n" + " B" used to join to "A\n\n B", now becomes "A" and "B"). Consider keeping the raw text on the Part and using the trimmed value only for the "is this episode empty" test.
  • redactStructuredOutputArgsForRecording still drops thoughtSignature from functionCall parts (return { functionCall: part.functionCall }). Since Gemini attaches the signature to the functionCall part, --resume still loses signatures on that wire — the same class of bug this PR fixes for thought parts. Worth a follow-up issue.
  • The boundary heuristic reconstructs information the wire already had. anthropicContentGenerator.ts sees content_block_start / content_block_stop with an explicit index for every thinking block, and currently emits nothing for them. Threading a real boundary signal through (block index on the chunk, or a synthetic episode-close part) would eliminate both the documented no-signature limitation and the [sig, text, sig] case, where openEpisodeText.length > 0 suppresses the flush and two distinct signatures get concatenated into one unusable blob. Given the effort already spent on the heuristic, an explicit boundary looks like the cheaper long-term shape.
  • Aliasing nit. this.history.push({ role: 'model', parts: consolidatedHistoryParts }) now shares the live array (previously a fresh spread). Nothing mutates it after this point today; a [...consolidatedHistoryParts] is cheap insurance against a future edit below the push.

Verdict

Direction and core algorithm: approve. Item 1 is a blocking regression — a verified behavior change from base that puts raw tool-call XML back into durable history. Item 2 needs an explicit decision (fix or document). Item 3 is a description/scoping gap. Items in §4 are follow-ups.

中文摘要

b7fe21c(base 912f7399)上审阅,本地跑通三个测试文件共 486/486 通过

主体算法是对的:用单遍的“推理片段”跟踪替换掉“全部合并、只留第一个签名”,每个片段保留自己的 Part、自己的签名和相对工具调用的原始位置;配套的 de-hoisting 和 XML 恢复谓词修复确实与主修复耦合,不应拆分。注释质量和“已知限制”的说明都很好。

合并前需要处理两点(其一为已验证的回归):

  1. 🔴 回归:XML 恢复会把原始 <invoke> 留在历史里。 contentTextpart.text && !part.thought 筛选,删除循环却用 isValidNonThoughtTextPart(额外排除带 thoughtSignature 的部件)。对于 { text, thoughtSignature }thought 为假的部件——正是 Gemini 的签名放置方式,loggingContentGenerator.ts:976-986 明确保留这种形状——它会被计入 contentText 却不会被删除,导致文本重复且原始 XML 残留在持久化历史中。探针在 base 通过、在本 PR 失败。建议两处统一使用同一个谓词,并补一个带 thoughtSignature 的回归测试。
  2. 🟡 未声明的行为变更:JSONL 现在会记录 inlineData/fileData 旧代码只记录 thought/文本/functionCall(redactStructuredOutputArgsForRecording 对非 functionCall 返回 null),新代码记录全部部件。探针确认:base 只记录 [{"text":...}],本 PR 还会写入完整 base64。对图像模型意味着会话文件无限膨胀、模型产出的媒体长期落盘,与该函数上方记录的隐私约定相悖;而 PR 声明“无破坏性改动”。请显式过滤或明确记录该决定。另:.filter(part => part !== null) 是死代码。
  3. 🟡 第二个 commit(ensureLeadingAssistantThinking)未写进 PR 描述,它在 manual 模式下部分恢复了第一个 commit 所反对的重排。请补充描述、说明证据(新增测试只断言我方转换结果,无法证明 Anthropic 确实会 400),并考虑按文档所述加上 tool_use 判断(当前实现是无条件重排最后一条 assistant 消息)。
  4. 🟢 后续可跟进:逐片段 .trim() 会改变 thinking 原文、可能影响签名校验;redactStructuredOutputArgsForRecording 仍会丢掉 functionCall 上的 thoughtSignature(Gemini 链路的 --resume 仍丢签名);边界启发式其实可以由 content_block_start/stop 提供显式边界来彻底解决;this.history.push 现在与本地数组共享引用,建议展开复制。

结论:方向与算法认可;第 1 点为阻塞性回归,第 2 点需要明确决策,第 3 点是描述/范围问题。

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Merge resolution: PR #8260 ← main

Root cause

Both this PR and main inserted a brand-new helper at the same anchor point in packages/core/src/core/anthropicContentGenerator/converter.ts — the blank line between mergeConsecutiveAssistantMessages and the cleanOrphanedToolCalls doc comment.

Git aligned on the shared /** … } skeleton there and reported one conflict (~lines 1439–1516).

Textual, not semantic

The sides share no logic. Each added an independent, self-contained function with its own doc comment. Resolution: keep both verbatim, PR's first then main's, each with its own closing brace. Everything else auto-merged — main's dropEmptyTextThinkingBlocks step and its two makeToolResultDeduper() call sites (in cleanOrphanedToolCalls and mergeConsecutiveUserMessages) landed cleanly beside the PR's ensureLeadingThinkingOnLatestAssistantMessage(messages) call.

What is load-bearing

  • Both helpers must stay defined — each has live callers. ensureLeadingThinking… is called once (guarded by options.ensureLeadingAssistantThinking, last in the pipeline after mergeConsecutiveUserMessages/stripTrailingAssistantPrefill); makeToolResultDeduper is called twice. Dropping either to "resolve" the conflict leaves a dangling reference.
  • Pipeline ordering is untouched by this merge. The PR's leading-thinking fix runs last, after main's new dropEmptyTextThinkingBlocks; main's comment requires dropUnsignedThinkingFromAssistantMessages to run before dropEmptyTextThinkingBlocks. A future edit reordering these three steps breaks that documented invariant.

What I could not verify

No build/typecheck/tests run here (out of scope). The merge is textually additive, so I expect no behaviour change beyond the union of both branches' effects. Watch in CI: both sides edit converter.test.ts (auto-merged, no conflict), but the combined pipeline now holds both dropEmptyTextThinkingBlocks and ensureLeadingThinking… — any assertion on pipeline count/order runs against that new reality.

中文说明

根因

本 PR 与 main 都在 converter.ts同一插入点mergeConsecutiveAssistantMessagescleanOrphanedToolCalls 文档注释之间)新增了辅助函数:PR #8260 加了 ensureLeadingThinkingOnLatestAssistantMessage;main 提交 40efe41ce#8163)加了 makeToolResultDeduper。Git 围绕该处共享的 /** … } 骨架对齐,报出一处冲突。

纯文本冲突,非语义冲突

两侧不共享任何逻辑,各自新增独立、自包含、带独立文档注释的函数。解决方式:原样保留两个函数,PR 的在前、main 的在后,各自带闭合花括号。其余部分自动合并——main 的 dropEmptyTextThinkingBlocks 流水线步骤及其两处 makeToolResultDeduper() 调用点干净地落在 PR 的调用旁边。

关键承重点

  • 两个辅助函数都必须保留定义,各自都有活跃调用方;删掉任何一个都会留下悬空引用。
  • 流水线顺序未被本次合并改动:PR 的 leading-thinking 修正在最后运行,位于 main 新增的 dropEmptyTextThinkingBlocks 之后;main 的注释要求 dropUnsignedThinkingFromAssistantMessages 先于 dropEmptyTextThinkingBlocks。未来重排这三步会破坏该不变量。

无法验证的部分

本命令不运行 build/typecheck/测试。合并在文本上纯叠加,预期行为变化仅为两分支效果之并集。CI 中需留意:两侧都改了 converter.test.ts(自动合并无冲突),但合并后的流水线同时包含 dropEmptyTextThinkingBlocksensureLeadingThinking…,关于流水线数量/顺序的断言将以这一新现实为准。

@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] XML-recovery predicate divergence leaks raw tool-call XML into durable history (maintainer @wenshao's item-1 blocker, re-verified at this commit by probe). contentText (geminiChat.ts:4327 and its recomputation at :4385) selects parts via part.text && !part.thought, which INCLUDES a {text, thoughtSignature} part carrying no thought flag — a real Gemini wire shape (loggingContentGenerator.ts:1004-1009 preserves exactly that shape and adds thought:true only when part.thought is truthy). But the removal loop (geminiChat.ts:4366) uses isValidNonThoughtTextPart, which additionally rejects thoughtSignature. The two predicates are not complements. When such a part's text contains raw <invoke> XML tool calls, recovery fires (contentText includes the part) yet the part is NOT removed (the removal loop excludes it): for a single such part textIndices is empty, nothing is removed, remainingText is spliced in at index 0, and the original part survives with the raw XML in history — duplicated prose plus a tool-call XML leak. Probe-confirmed at 2a76e3e: current code yields [{text:'Sure.'}, {text:'Sure.\n<invoke …>', thoughtSignature}, {functionCall}]; aligning the two predicates fixes it. Fix: use one predicate (e.g. isValidNonThoughtTextPart) for contentText, its recomputation, and textIndices, and add a regression test with thoughtSignature on a plain-text part that carries XML.

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

Comment thread packages/core/src/core/geminiChat.ts Outdated
Addresses a Critical from PR QwenLM#8260 review (and the related "three
uncoordinated predicates" Suggestion it escalated): contentText's
filter (`part.text && !part.thought`) and the XML-recovery removal
loop's filter (`isValidNonThoughtTextPart`, which additionally rejects
any part carrying `thoughtSignature`) disagreed on what counts as
"visible text." A part with `thoughtSignature` set but no `thought:
true` -- a real wire shape (loggingContentGenerator.ts's stream
aggregation spreads `thought` and `thoughtSignature` independently) --
was picked up by contentText for XML detection but survived the
removal loop untouched: recovery fired, but the raw `<invoke>` XML was
never stripped, leaking it into durable history duplicated alongside
the recovered functionCall.

Introduce a single `isVisibleTextPart` predicate (`Boolean(part.text)
&& !part.thought`) shared by contentText's initial computation, its
post-recovery recompute, and the removal loop's textIndices scan.
Deliberately the looser of the two prior predicates, not the stricter
one: narrowing contentText itself to exclude thoughtSignature-bearing
text would make `hasAnyContent` treat genuine visible text as absent,
throwing "Model stream ended with empty response text" on ordinary
turns. flushThoughtEpisode always sets `thought: true` on episode
parts, so `!part.thought` alone (already contentText's semantics)
already protects reasoning episodes from the removal loop without
isValidNonThoughtTextPart's stricter signature exclusion.

Adds a regression test that reproduces the leak on unfixed code
(confirmed failing before this fix, passing after) with a plain-text
part carrying a stray thoughtSignature and XML content.

Also addresses two outstanding test-coverage Suggestions from the same
review round:
- converter.test.ts: a multi-thinking-run case for
  ensureLeadingAssistantThinking, guarding the "only the first run
  moves" invariant against a hoist-all-thinking mutant that the
  existing single-run test couldn't catch.
- anthropicContentGenerator.test.ts: a generator-level adaptive-mode
  test mirroring the manual-mode one, guarding the `thinking?.type ===
  'enabled'` gate against a `!!thinking` regression that would
  reintroduce the hoist-every-thinking corruption on adaptive models.
- geminiChat.test.ts: asserts the interleaved-episode test's recorded
  JSONL turn (not just in-memory history) preserves both reasoning
  episodes and their signatures, guarding --resume fidelity against a
  recording-only regression that in-memory assertions can't see.
@netbrah
netbrah requested a review from doudouOUC as a code owner August 3, 2026 19:44
@netbrah

netbrah commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the Critical from the latest review round ("XML-recovery predicate divergence leaks raw tool-call XML into durable history"), fixed in da4b3e381.

Confirmed the finding by reproduction before fixing: contentText's filter (part.text && !part.thought) and the XML-recovery removal loop's filter (isValidNonThoughtTextPart, which additionally excludes any part carrying thoughtSignature) disagreed on what counts as "visible text." A part with thoughtSignature set but no thought: true — a real wire shape (loggingContentGenerator.ts's stream aggregation spreads thought and thoughtSignature independently) — was picked up by contentText for XML detection but survived the removal loop untouched, leaking the raw <invoke> XML into history duplicated alongside the recovered functionCall.

Fix: a single isVisibleTextPart predicate now backs contentText's initial computation, its post-recovery recompute, and the removal loop's textIndices scan — the same fix requested by an earlier Suggestion on this PR flagging the three-way predicate duplication. Deliberately kept the looser predicate (not isValidNonThoughtTextPart) as the shared one: narrowing contentText itself would make hasAnyContent treat genuine visible text as absent on ordinary turns. flushThoughtEpisode always sets thought: true on episode parts, so !part.thought alone (already contentText's semantics) was always sufficient to protect reasoning episodes from the removal loop — the extra signature exclusion in isValidNonThoughtTextPart was unneeded there and is what caused the divergence.

Added a regression test with a plain-text part carrying a stray thoughtSignature and XML content — confirmed it fails on unfixed code (raw XML survives in history) and passes after the fix.

Also landed the two outstanding test-coverage Suggestions from the same review round (multi-thinking-run case for ensureLeadingAssistantThinking, and a generator-level adaptive-mode test), plus a JSONL-recording regression test for interleaved reasoning episodes. All addressed inline threads are marked resolved.

Full suite: 500 passing / 1 pre-existing unrelated failure (a User-Agent header assertion that fails identically on origin/main due to an environment variable in this sandbox, confirmed unrelated to this change). Typecheck and lint clean.

One item for visibility: this branch was recently updated with a merge of main, but main has since advanced enough that GitHub now reports mergeStateStatus: CONFLICTING (conflict in geminiChat.ts against the latest main). Flagging rather than resolving unprompted, since geminiChat.ts is high-traffic and I'd rather have alignment before rebasing again.

Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/core/src/core/anthropicContentGenerator/converter.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/core/src/core/anthropicContentGenerator/converter.ts
Palanisamy, Dinesh added 3 commits August 3, 2026 21:04
…t-consolidation

# Conflicts:
#	packages/core/src/core/geminiChat.ts
…urns

Addresses the Critical from PR QwenLM#8260's latest review round, verified by
tracing the actual code before fixing (not taken on the reviewer's
word): flushThoughtEpisode's own "Known limitation" note already
acknowledged that a stream cut off before an episode's terminating
signature-only chunk arrives (SSE drop, MAX_TOKENS) leaves that
episode unsigned. Left in history alongside a tool_use in the SAME
turn, this permanently wedges a session: once the tool result is
appended, the turn enters dropUnsignedThinkingFromAssistantMessages's
"active tool-use chain" and every subsequent request throws on
proxy-hosted adaptive Claude (native Anthropic rejects the unsigned
block itself instead) -- neither is recoverable without editing
history out-of-band, since the malformed turn is now a permanent part
of the session's history.

Fix: after the trailing flushThoughtEpisode() call, if the turn has a
tool_use and the last consolidated part is an unsigned trailing
thought episode, drop it before it can ever reach history. Scoped to
`hasToolCall` because a dangling unsigned episode with no tool_use in
the same turn is already filtered out safely downstream (it never
enters the active-chain path). Added a regression test reproducing the
exact wedge scenario, confirmed failing on unfixed code (the unsigned
episode survived in history) and passing after.

Also addresses four Suggestions from the same review round, each
verified against the actual code (one live-mutated to confirm it
catches what's claimed) rather than accepted at face value:
- converter.ts: added a test with two non-consecutive assistant
  messages (separated by a user turn) to discriminate
  ensureLeadingAssistantThinking's backward scan from a forward-scan
  mutation that reorders the wrong turn -- confirmed by temporarily
  applying the mutation and observing exactly this new test fail.
- converter.ts: added a multi-block first-thinking-run test to
  discriminate the run-extension loop from a `runEnd = runStart + 1`
  mutation that would split a multi-block run apart -- confirmed the
  same way.
- geminiChat.ts: added two tests for the episode-split condition's
  `openEpisodeText.length > 0` and `openEpisodeSignature !== ''`
  clauses (signature arriving before any text; multiple text deltas
  within one still-open episode, the normal live-streaming shape) --
  each confirmed to fail when its corresponding clause is removed.
- geminiChat.ts / converter.ts: fixed a stale comment (the JSONL
  recording no longer reads the recomputed contentText, it reads
  consolidatedHistoryParts directly) and qualified
  dropEmptyTextThinkingBlocks's doc, which read as contradicting
  flushThoughtEpisode's "still potentially replayable" rationale for
  the same empty-text+signature shape -- clarified that the two are
  consistent (disposability of non-latest-turn thinking, not
  invalidity of the shape itself), with a cross-reference each way.

One Suggestion from the same round was checked and found NOT to hold:
a claim that no test pins flushThoughtEpisode's "drop a whitespace-only,
signature-less episode" guard. Forcing that guard to unconditionally
true and running the full suite shows this is false -- "should
preserve text parts that stream in the same chunk as a thought" (an
existing test) goes red under exactly that mutation. No change made
for this one.
…e dangling-episode fix

A scoped multi-model architectural review of the reasoning-episode
consolidation logic (3 independent reviewers, one per major model
family) converged on the same Critical finding, plus a second real
gap and a lower-priority structural one. Every finding was
independently re-verified against the actual code (traced by hand,
or confirmed/refuted via live mutation) before acting -- one line of
investigation that looked promising turned out to cause a real
regression and was redesigned rather than shipped as-is (see below).

Critical (corroborated by all 3 reviewers, verified by hand-tracing
the control flow myself): the per-stream trailing-pop fix from the
previous commit only inspects a single `processStreamResponse` call's
own output. The MAX_TOKENS *recovery* loop explicitly proceeds only
when the truncated turn has NO functionCall yet -- exactly the
precondition under which the per-stream check's `hasToolCall` is
false and never fires. If the recovery continuation then calls a
tool (an ordinary agentic-loop event), `coalesceRecoveryPairs` merges
the two attempts via `appendRecoveryContinuationParts`, whose dedup
anchor is blind to `thought` parts -- reintroducing the exact
permanent-wedge hazard the previous fix targeted, just via the
cross-request merge path instead of a single stream. Fixed by
re-running the same trailing-only check on the truncated turn's own
parts immediately before the merge, using "does the continuation
introduce a functionCall" as the `hasToolCall` signal.

A second reviewer-proposed fix (broadening the trailing-only check to
scan the whole parts array, to also catch an unsigned episode
immediately preceding a functionCall within a single stream) was
implemented, then REVERTED after the full test suite caught a real
regression: DeepSeek legitimately emits unsigned thinking blocks
right before a functionCall as its normal, complete wire shape
(DeepSeek doesn't validate thinking signatures the way Anthropic
does). A whole-array scan can't distinguish "truncated mid-episode"
from "a provider that doesn't sign its thinking" -- only the trailing
position can, since a stream's own truncation can only ever leave the
dangling episode trailing (anything that followed it in the same
stream would already have flushed it). Kept the check trailing-only
and added a test pinning this as accepted residual risk, matching the
code's own pre-existing "Known limitation" note on wire-protocol
non-compliance.

Lower-priority structural fix (found by one reviewer, verified by
reading the code myself): `dropEmptyTextThinkingBlocks` computes "the
latest assistant message" once, before `stripTrailingAssistantPrefill`
can later pop a genuinely-empty trailing message and promote an
earlier one to "new latest" -- stale index. Verified the trigger
conditions overlap in practice (`stripTrailingAssistantPrefill` is
gated on model version 4.6+; `ensureLeadingAssistantThinking` is
gated on manual/explicit-budget mode; both are true simultaneously
for exactly the "4.6+ model with an explicit budget_tokens override"
configuration this PR's own escape-hatch targets). Fixed by
reordering the pipeline to run `stripTrailingAssistantPrefill` before
`dropEmptyTextThinkingBlocks`, preserving `mergeConsecutiveUserMessages`'s
existing adjacency to `dropEmptyTextThinkingBlocks` so its own
cleanup invariant (fixing up newly-adjacent user messages after an
assistant message is dropped) is unaffected.

Every fix and every reverted attempt was verified against the full
`geminiChat.test.ts` + `converter.test.ts` + `anthropicContentGenerator.test.ts`
suites (540 tests, only the one pre-existing unrelated User-Agent
failure) and against targeted mutation testing: each new regression
test was confirmed to fail when its guarded code path is disabled or
reverted, and to pass once restored.
@netbrah

netbrah commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

R3-1 — confirmed, and it was a hole in my own round-2 fix. Fixed in 60d4103b7.

The reviewer is right. I reproduced it before changing anything.

Round 2 added the third dropDanglingUnsignedTrailingThought call site after recovery.remainingText was spliced back in. When the dangling unsigned episode precedes the consumed XML text part, the re-inserted text lands behind the episode, the trailing-only check sees a text part last and no-ops, and the appended calls persist [thought(unsigned), text, functionCall] — the same permanent wedge the call site exists to prevent, reached by a different shape.

Where I went wrong in round 2: I did consider a preceding unsigned episode and classified it as the documented non-trailing residual risk. That was the wrong call. The episode is trailing at the moment the consumed text parts are spliced out — only the re-insertion pushes it out of last position. So this was never the accepted residual case; it was a placement bug.

Fix is the one you flip-verified: move the drop into the window after the splice-out and before both the re-insertion and the append, which is the only point where a dangling episode is guaranteed to be last. insertAt is now clamped against the post-drop length, since the drop can shorten the array. I expanded the comment to say the placement is load-bearing on both sides, so a future edit doesn't reorder it back.

Regression test uses exactly the trigger shape you named — unsigned episode, then a plain-text part carrying a stray thoughtSignature with no thought flag, non-empty remainingText so the ordering is observable. Confirmed red before the change, green after. 575/575 across geminiChat.test.ts and anthropicContentGenerator/ on top of the merged main; tsc, eslint, prettier clean.

R2-2 — this one needs a maintainer ruling, not more code

The review notes correctly that the trailing-only false positive "cannot be ruled fixed" because the minimum-action path is conditioned on a maintainer accepting the residual reasoning-loss, and no such ruling is on the PR. That's fair, so making the ask explicit rather than leaving it implied:

@wenshao — could you rule on this trade-off?

dropDanglingUnsignedTrailingThought pops an unsigned trailing thought when the turn has a tool call. A non-signing provider (DeepSeek) truncated mid-reasoning after a tool call produces a byte-identical array shape to a truncated signing-provider episode, so the pop also deletes legitimate DeepSeek reasoning from both history and the JSONL record.

The two options, as I see them:

  1. Accept the loss (what's implemented). A truncated non-signing turn loses a trailing reasoning fragment. Pinned by a tripwire test and stated in the function's doc as an accepted false positive.
  2. Gate the pop on "this turn carries at least one signature." Fixes this call site, but is wrong at the recovery-coalescing call site, where a truncated turn legitimately has no signature anywhere yet — the coalescing regression test fails. Making that work needs a per-call-site decision, so it's a larger change.

My read is that option 1 is the right trade: losing a trailing reasoning fragment on a provider that never validates signatures is strictly cheaper than permanently wedging a session on a provider that does, and the wedge is unrecoverable without editing history out of band. But that's a product call about which failure you'd rather ship, not something I should decide unilaterally — happy to implement option 2 across both call sites if you'd rather not lose the reasoning.

The other two open threads (geminiChat.ts:4916 and :1096) are the round-2 items; 4916 is superseded by the fix above, and 1096 is this ruling.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Not explored to full depth (tool budget reached): chunk 7: I have not independently verified that the JSONL/resume consumers accept thought parts within the recorded message (cross-file, explicitly out of scope per th…; chunk 7: did not trace JSONL/ --resume consumers' handling of thought parts now included in the recorded message array — cross-file, explicitly outside a chunk agent'…; PR #8260 replaces geminiChat.ts's merge-all/keep-first-si...: none — all checks I started were completed within budget.; PR #8260 replaces geminiChat.ts's merge-all/keep-first-si...: none — I finished the walk within budget; no check was left unfinished.; chunk 3: none — all planned checks completed within budget..

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

已审查。 建议见行内评论。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

未探索到全部深度(达到工具调用预算):chunk 7:I have not independently verified that the JSONL/resume consumers accept thought parts within the recorded message (cross-file, explicitly out of scope per th…;chunk 7:did not trace JSONL/ --resume consumers' handling of thought parts now included in the recorded message array — cross-file, explicitly outside a chunk agent'…;PR #8260 replaces geminiChat.ts's merge-all/keep-first-si...:none — all checks I started were completed within budget.;PR #8260 replaces geminiChat.ts's merge-all/keep-first-si...:none — I finished the walk within budget; no check was left unfinished.;chunk 3:none — all planned checks completed within budget.

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +14324 to +14327
const hasUnsignedThought = (lastEntry.parts ?? []).some(
(part) => part.thought && part.text && !part.thoughtSignature,
);
expect(hasUnsignedThought).toBe(false);

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 coalescing-site drop test pins only the drop direction; there is no complementary test that a SIGNED trailing episode on the truncated turn survives the coalesceRecoveryPairs drop call site. The XML-recovery call site pins both directions (its complement's comment names the mutation it kills), but this site has only the drop direction — the nearby provenance test (~14218) uses an unsigned continuation thought with no functionCall, so it does not cover it. Probe-verified at this commit: an over-pop mutation localized to this call site passes the full 337-test suite, while the identical mutation at the XML site fails 2 tests. — Failure scenario: a signing provider completes an episode as the trailing part of a turn truncated at MAX_TOKENS and the recovery continuation introduces a functionCall → a future over-pop at this site silently deletes a complete, replayable episode from the merged turn, and no test fails.

Suggested fix — add a sibling test in this describe:

it('keeps a SIGNED trailing reasoning episode on the truncated turn when coalescing recovery pairs', async () => {
  // truncated turn ends in { text: 'complete episode', thought: true, thoughtSignature: 'sig-prev' }
  // (MAX_TOKENS), continuation introduces a functionCall (STOP)
  // assert the merged last entry still contains the part with
  // thoughtSignature: 'sig-prev' alongside the functionCall
});
中文说明

coalescing 调用点的 drop 测试只钉住了「丢弃」方向;没有互补测试钉住「被截断轮次上带签名(SIGNED)的尾部 episode 在 coalesceRecoveryPairs 的 drop 调用点之后仍然存活」。XML 恢复调用点两个方向都有测试(其互补测试的注释明确写出了它能杀死的变异体),但本调用点只有丢弃方向——附近的 provenance 测试(约 14218 行)使用的是无签名的 continuation thought 且没有 functionCall,覆盖不到这里。已在本 commit 上用探针验证:把过度弹出(over-pop)变异局限在本调用点后,全套 337 个测试依然通过;同样的变异放在 XML 调用点则会使 2 个测试失败。— 失败场景:签名链路在 MAX_TOKENS 截断轮次的尾部完成了一个 episode,且 recovery continuation 引入了 functionCall → 未来在本调用点的过度弹出会静默删除合并轮次中一个完整、可重放的 episode,且没有任何测试失败。

建议修复:在同一 describe 中补一个互补测试(见上方代码块)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +3254 to +3257
expect(lastEntry.parts).toEqual([
{ text: 'ep1', thought: true, thoughtSignature: 'sig1' },
{ functionCall: { id: 'call1', name: 'tool', args: {} } },
]);

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] Both drop-path tests (this truncation test and the accepted-false-positive tripwire at ~3633) assert only chat.getHistory() on the non-recording chat from beforeEach; nothing pins that the dropped dangling episode is also absent from the recordAssistantTurn JSONL message. The tripwire's own comment asserts "the reasoning is gone from history AND from the JSONL record" — the JSONL half of that claim is asserted but never verified. Your own adjacent test (~3141) was created explicitly to guard history/JSONL divergence in the opposite direction. — Failure scenario: today the agreement is order-dependent (recordArgs.message is built from the already-dropped consolidatedHistoryParts at geminiChat.ts:5176; drop at 4916/5007) → a future reorder that moves the drop after the record call (a plausible attempt to close the coalescing-site JSONL drift the function doc itself documents) leaves every assertion green while the JSONL keeps [thought(unsigned), functionCall, thought(unsigned)]; --resume rehydrates the wedge shape and the resumed session throws from dropUnsignedThinkingFromAssistantMessages on every subsequent request.

Suggested fix — mirror the ~3141 recording test: construct a chat with a recordAssistantTurn mock, replay this truncation fixture, and assert the recorded message equals the two surviving parts (the dropped episode absent from the recorded message as well as from history).

中文说明

两条 drop 路径测试(本截断测试与约 3633 行的 accepted-false-positive 测试)都只对 beforeEach 中不带录制服务的 chat 断言 chat.getHistory();没有任何测试钉住「被丢弃的悬空 episode 同样不在 recordAssistantTurn 的 JSONL 消息里」。tripwire 测试自己的注释声称 "the reasoning is gone from history AND from the JSONL record"——其中 JSONL 那一半只是被声称、从未被验证。而你们在约 3141 行的相邻测试正是为了在相反方向上防止 history/JSONL 分歧而专门创建的。— 失败场景:当前两者一致是顺序依赖的(recordArgs.message 在 geminiChat.ts:5176 处由已经被 drop 过的 consolidatedHistoryParts 构建;drop 在 4916/5007)→ 未来把 drop 移到 record 调用之后的重排(函数文档自己记载的 coalescing 调用点 JSONL 漂移很可能诱发这种修复尝试)会使所有现有断言保持绿色,而 JSONL 仍保留 [thought(无签名), functionCall, thought(无签名)]--resume 会把这个死锁形状重新载入,恢复后的会话在之后每次请求都从 dropUnsignedThinkingFromAssistantMessages 抛错。

建议修复:仿照约 3141 行的录制测试,用带 recordAssistantTurn mock 的 chat 重放本截断 fixture,断言录制下来的 message 只包含两个存活部件(被丢弃的 episode 既不在 history 也不在录制消息中)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +16001 to +16003
const thoughtPart = parts.find((p) => p.thought);
expect(thoughtPart).toBeDefined();
expect(thoughtPart?.thoughtSignature).toBe('sig-should-survive');

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] All four new XML-recovery episode tests assert only part presence (.some() / .find()), never order — the replay-load-bearing invariant that a preserved reasoning episode stays BEFORE the recovered functionCall is unpinned, even though this suite's own pre-existing prefix test pins the analogous text/functionCall order by index comparison (~16208), and the provenance test at ~14217 pins thought-before-text with an explicit wire-contract rationale. Probe-verified at this commit: mutating the push to splice recovery.functionCallParts before the first thought part (yielding [functionCall, episode]) leaves all six XML-recovery tests green; adding the index assertion below fails with expected 1 to be less than 0 under the mutation and passes without it. — Failure scenario: a future refactor of the splice/push sequencing (geminiChat.ts:~4988-5018) — e.g. moving the functionCallParts push earlier, or adding an episode hoist like appendRecoveryContinuationParts' → the recovered turn goes out as [functionCall, episode] and a signature-validating thinking provider rejects it on the next request, the wedge class this PR exists to prevent.

Suggested change
const thoughtPart = parts.find((p) => p.thought);
expect(thoughtPart).toBeDefined();
expect(thoughtPart?.thoughtSignature).toBe('sig-should-survive');
const thoughtPart = parts.find((p) => p.thought);
expect(thoughtPart).toBeDefined();
expect(thoughtPart?.thoughtSignature).toBe('sig-should-survive');
expect(
parts.findIndex((p) => p.functionCall),
).toBeGreaterThan(parts.findIndex((p) => p.thought));

(Add the same index assertion to the signed-trailing test below as well.)

中文说明

四个新的 XML 恢复 episode 测试全部只断言部件「存在」(.some() / .find()),从不断言顺序——「被保留的推理 episode 必须排在恢复出的 functionCall 之前」这一对重放至关重要的不变量没有被钉住,尽管本套件既有的 prefix 测试用索引比较钉住了类似的 text/functionCall 顺序(约 16208 行),且约 14217 行的 provenance 测试以明确的线约定理由钉住了 thought 先于 text。已在本 commit 上用探针验证:把 push 变异为将 recovery.functionCallParts 插到第一个 thought 部件之前(产生 [functionCall, episode])后,全部六个 XML 恢复测试依然绿色;加上下方的索引断言后,该变异下测试以 expected 1 to be less than 0 失败,未变异时通过。— 失败场景:未来对 splice/push 时序的重构(geminiChat.ts:约 4988-5018)——例如把 functionCallParts 的 push 提前,或加上类似 appendRecoveryContinuationParts 的 episode 提升 → 恢复出的轮次以 [functionCall, episode] 发出,签名校验型 thinking 链路会在下一次请求拒绝它——正是本 PR 要防止的死锁类别。

上方 suggestion 块在断言存在性的基础上补了索引顺序断言;请同样补到下方的 signed-trailing 测试。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +2884 to +2885
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',

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 fixture constructs an API-invalid parameter combination: max_tokens: 500 together with budget_tokens: 42_000 below violates Anthropic's budget_tokens < max_tokens requirement, which this same file documents in buildThinkingConfig ("Anthropic requires budget_tokens < max_tokens"). buildSamplingParameters performs no clamp between the two values, so the invalid pair goes out as-is; the test passes only because the client is mocked. The test's own comment presents this exact config as the realistic explicit-budget escape hatch, but reproducing it against the real API 400s on the sampling parameters before the behavior under test is even reached; if the suite ever gains wire-contract validation, this test fails for a reason unrelated to what it pins. — Concrete cost: the canonical manual-mode fixture in the suite models a request Anthropic rejects.

Suggested change
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
samplingParams: { max_tokens: 64_000 },
schemaCompliance: 'auto',

(Alternatively, set a per-request thinkingConfig.thinkingBudget below 500, which would additionally exercise applyRequestBudgetCap.)

中文说明

该 fixture 构造了一个 API 非法的参数组合:下方的 budget_tokens: 42_000max_tokens: 500 违反了 Anthropic 的 budget_tokens < max_tokens 要求——本文件的 buildThinkingConfig 里就记载着这条要求("Anthropic requires budget_tokens < max_tokens")。buildSamplingParameters 不会在两个值之间做任何裁剪,因此这个非法组合会原样发出;测试之所以通过,只是因为客户端是 mock 的。测试自己的注释把这个配置呈现为真实的显式预算 escape hatch,但拿它去打真实 API 会在进入被测行为之前就因采样参数 400;如果未来该套件加上线契约校验,这个测试会因为与被测行为无关的原因失败。— 具体代价:套件中 manual 模式的基准 fixture 模拟的是一个会被 Anthropic 拒绝的请求。

上方 suggestion 将 max_tokens 提高到预算之上;也可以改为设置每请求 thinkingConfig.thinkingBudget(低于 500),这样还能顺带覆盖 applyRequestBudgetCap

— qwen3.8-max via Qwen Code /review (v0.21.10)

expect(blocks[0]?.signature).toBe('sigB');
});

it('ensureLeadingAssistantThinking moves only the first thinking run when the assistant turn has multiple thinking/tool_use pairs', () => {

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] Every fixture in this suite — and both generator-level tests — feeds a merged assistant message whose first thinking run sits BEFORE the first tool_use block; the contract when the first run starts AFTER a tool_use block is pinned in neither direction. Probe-verified current behavior at this commit: the run IS relocated ([text, tool_use t1, thinking E2, tool_use t2][thinking, text, tool_use, tool_use]; the scan is blocks.findIndex(isThinking) with no tool_use-position condition). The shape is reachable via the merge pass's own documented sources (a truncated turn ending in a functionCall whose recovery continuation starts with thought+functionCall). — Failure scenario: a future refinement adding "skip when runStart > firstToolUseIndex" (plausible — interleaved mode legitimately allows thinking between tool_use blocks) leaves all tests green and ships the text-leading tool_use shape on manual-mode requests → the issue-3786 HTTP-400 class this option was added to prevent.

Suggested fix — add one case pinning whichever order is the accepted contract:

it('pins the contract when the first thinking run starts after a tool_use block', () => {
  // consecutive model turns: [{text}], [{functionCall t1}],
  // [{thought E2, thoughtSignature}, {functionCall t2}], then a user turn
  // carrying both tool_results; convert with { ensureLeadingAssistantThinking: true }
  // and assert the exact full block order (today: ['thinking', 'text', 'tool_use', 'tool_use'])
});
中文说明

本套件的所有 fixture——以及两个 generator 层测试——送入的合并后 assistant 消息,其第一个 thinking run 都位于第一个 tool_use 块之前;当第一个 run 起始于 tool_use 块之后时,契约在两个方向上都没有被钉住。已在本 commit 上用探针验证当前行为:该 run 会被重排([text, tool_use t1, thinking E2, tool_use t2][thinking, text, tool_use, tool_use];扫描用的是 blocks.findIndex(isThinking),没有任何 tool_use 位置条件)。该形状可以经由 merge 通道自己记载的来源达到(以 functionCall 结尾的被截断轮次,其 recovery continuation 以 thought+functionCall 开头)。— 失败场景:未来加入「当 runStart > firstToolUseIndex 时跳过」的精化(这很合理——interleaved 模式本来就允许 thinking 出现在 tool_use 块之间)会使所有测试保持绿色,并在 manual 模式请求中发出 text 打头且带 tool_use 的形状 → 正是本选项要防止的 issue-3786 HTTP-400 类别。

建议修复:补一个用例,把可接受契约的顺序钉住(见上方代码块)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +16114 to +16116
const parts = chat.getHistory()[1]!.parts ?? [];
expect(parts.some((p) => p.functionCall)).toBe(true);
expect(parts.some((p) => p.text?.includes('<invoke'))).toBe(false);

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 test (and the stray-signature test at ~16260) deliberately makes remainingText non-empty — 'Sure.' — but never asserts that the remaining visible text survives recovery; it asserts only functionCall presence and absence of the episode/XML. Current code preserves it (the drop pops the episode, then the splice re-inserts remainingText — final parts [{text:'Sure.'}, {functionCall}]), but the property is unpinned. The pre-existing prefix test covers only the plain-text shape, where the drop never fires. — Failure scenario: a future refactor of the XML-recovery branch that skips or conditionalizes the remainingText re-insert on the path where the drop fires silently deletes user-visible response text from this.history and the JSONL — text the user saw streamed is permanently absent on --resume — with every assertion in both tests green.

Suggested change
const parts = chat.getHistory()[1]!.parts ?? [];
expect(parts.some((p) => p.functionCall)).toBe(true);
expect(parts.some((p) => p.text?.includes('<invoke'))).toBe(false);
const parts = chat.getHistory()[1]!.parts ?? [];
expect(parts.some((p) => p.functionCall)).toBe(true);
expect(parts.some((p) => p.text?.includes('<invoke'))).toBe(false);
expect(parts.some((p) => p.text === 'Sure.')).toBe(true);

(Add the same assertion to the stray-signature test at ~16260.)

中文说明

本测试(以及约 16260 行的 stray-signature 测试)刻意让 remainingText 非空——即 'Sure.'——但从未断言这段剩余可见文本在恢复后存活;它们只断言了 functionCall 的存在和 episode/XML 的缺席。当前代码确实保留了它(drop 弹出 episode 后,splice 会把 remainingText 重新插入——最终部件为 [{text:'Sure.'}, {functionCall}]),但这个性质没有被钉住。既有的 prefix 测试只覆盖纯文本形状,那里 drop 从不触发。— 失败场景:未来对 XML 恢复分支的重构,如果在 drop 触发的路径上跳过或条件化 remainingText 的重插,会静默删除用户可见的响应文本——this.history 和 JSONL 都将失去它,用户在流式输出中亲眼看到的文本在 --resume 后永久缺席——而这两个测试的所有断言依然绿色。

上方 suggestion 补上了 'Sure.' 存活断言;请在约 16260 行的 stray-signature 测试中也补上同样的断言。

— qwen3.8-max via Qwen Code /review (v0.21.10)

… tests

All six round-4 findings were Suggestions against tests added earlier in this
PR, and three were probe-verified mutation escapes -- guards that were
claimed as regression coverage but did not actually catch the regression
they named. Each fix below is verified by applying the mutation, observing
the new assertion go red, and restoring.

R4-3: the four XML-recovery episode tests asserted part presence only, never
order, even though episode-before-tool_use is the replay invariant the whole
PR exists to protect. Splicing recovery.functionCallParts ahead of the
episode previously left every one of them green. Added index-order
assertions to three of them; the mutation now fails 5 tests.

R4-1: the coalescing call site pinned only the drop direction, so an over-pop
localized there passed all 337 tests while the XML site's equivalent
mutation failed 2. Added the complementary test -- a SIGNED trailing episode
on the truncated turn survives when the continuation introduces a
functionCall. The over-pop mutation now fails it.

R4-6: two tests deliberately set a non-empty remainingText ('Sure.') to make
the drop-vs-reinsert ordering observable, then never asserted the text
survived. Skipping the re-insert previously left them green; it now fails 3
tests. This is user-visible prose that `--resume` would silently lose.

R4-2: the accepted-false-positive tripwire's own comment claims the reasoning
is gone "from history AND from the JSONL record", but only history was
asserted. That agreement is ordering-dependent -- recordArgs.message is built
from the already-dropped array -- so a future reorder placing the drop after
the record call would keep every assertion green while the JSONL retained the
wedge shape for --resume to rehydrate. Now asserts both surfaces.

R4-5: no fixture covered a first thinking run starting AFTER a tool_use
block, so the contract was pinned in neither direction. Current behavior
relocates it (the scan is a bare findIndex with no position condition).
Pinned, so a plausible "skip when the run starts after the first tool_use"
refinement can't silently ship the text-leading shape QwenLM#3786 rejects.

R4-4: the canonical manual-mode fixture paired max_tokens: 500 with
budget_tokens: 42_000, violating Anthropic's documented
budget_tokens < max_tokens rule. buildSamplingParameters does not clamp
between them, so the fixture modelled a request the real API 400s on and
passed only because the client is mocked. Raised to 64_000.

Also widened the makeChunk test helper's part type to carry
thoughtSignature, which the new signed-episode fixture needs.

577 tests pass across geminiChat.test.ts and anthropicContentGenerator/;
tsc, eslint and prettier clean.
@netbrah

netbrah commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

All six addressed in ad1c54bc7. Every one was a fair hit, and the three probe-verified ones were the useful kind: guards I added earlier in this PR and described as regression coverage that did not actually catch the regression they named. Each fix below was verified by applying the mutation, watching the new assertion go red, and restoring.

R4-3 — order was never pinned. Correct, and this was the worst of the three, because episode-before-tool_use is the replay invariant the entire PR exists to protect and I asserted only presence. Confirmed your probe: splicing recovery.functionCallParts ahead of the episode left all of them green. Added index-order assertions to three tests; that mutation now fails 5.

One correction while doing it — my first attempt asserted that the signed trailing episode should follow the recovered call, since it arrived after the XML text on the wire. That was wrong and the test caught me: the consumed text part is spliced out and the recovered calls are appended last, so every surviving episode precedes them regardless of arrival order. The assertion now pins that, which is the shape a signature-validating provider actually requires.

R4-1 — coalescing site pinned in one direction only. Confirmed: an over-pop localized there passes all 337 tests, while the equivalent mutation at the XML site fails 2. Added the complementary test — a signed trailing episode on the truncated turn survives when the continuation introduces a functionCall. The over-pop now fails it.

R4-6 — remainingText survival unasserted. Confirmed. Both tests deliberately set 'Sure.' to make the drop-vs-reinsert ordering observable and then never checked the text came back. Skipping the re-insert left them green; it now fails 3. That is user-visible prose --resume would have lost silently.

R4-2 — the JSONL half of the claim was never verified. Also correct, and slightly embarrassing: the tripwire's own comment asserts the reasoning is gone "from history AND from the JSONL record" while only history was checked. As you note, that agreement is ordering-dependent — recordArgs.message is built from the already-dropped array — so exactly the reorder someone would attempt to close the coalescing-site JSONL drift would keep every assertion green. Now asserts both surfaces via a recording chat.

R4-5 — first thinking run starting after a tool_use. Confirmed unpinned in both directions, and confirmed current behavior relocates it (the scan is a bare findIndex(isThinking) with no position condition). Pinned. I verified the mutation you predicted: adding skip when runStart > firstToolUseIndex now fails this test rather than shipping the text-leading shape.

R4-4 — API-invalid fixture. Confirmed: max_tokens: 500 with budget_tokens: 42_000 violates the budget_tokens < max_tokens rule this file's own buildThinkingConfig documents, and buildSamplingParameters doesn't clamp between them, so the canonical manual-mode fixture modelled a request the real API rejects. Raised to 64_000.

Also widened the makeChunk helper's part type to carry thoughtSignaturetsc caught that the new signed-episode fixture needed it.

577 tests pass across geminiChat.test.ts and anthropicContentGenerator/; tsc, eslint and prettier clean.


Still outstanding on this PR is the one thing I can't settle myself: the R2-2 trade-off ruling on dropDanglingUnsignedTrailingThought's accepted false positive — whether losing a trailing reasoning fragment for a non-signing provider is the right trade against permanently wedging a signing one. Options and my reasoning are in the earlier comment. @wenshao whenever you have a moment.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

Unresolved, please confirm:

  • [Critical] packages/core/src/core/geminiChat.ts:1096 — round-2 blocker (ledger R2-2): the trailing-only drop still cannot distinguish a truncated signing-provider episode from a non-signing provider's ordinary trailing thought. The author completed the blocker's minimum-action path (the overclaiming doc is corrected, and the truncated-all-unsigned shape is pinned by a tripwire test asserting BOTH the history and the JSONL surfaces — both verified at this commit), but that path is conditioned on a maintainer ruling the residual reasoning-loss an acceptable trade-off, and no such ruling is on the PR — the mechanism still fires by design, so the blocker cannot be ruled fixed. Maintainer confirmation needed.

Not explored to full depth (tool budget reached): chunk 7: no typecheck / unit test run in this review worktree (node_modules not installed; NO_TSC). Verified by reading.; You are review agent reverse-audit — Reverse audit agen...: none — all checks above completed within budget.; You are review agent reverse-audit — Reverse audit agen...: none — all checks completed within budget.; chunk 2: did not execute converter.test.ts — the review worktree has no node_modules and a full monorepo npm install was disproportionate to the soft tool budget; …; PR #8260 replaces geminiChat.ts's merge-all/keep-first-si...: none — all planned checks completed within budget., and 6 more.

中文说明

已审查。 建议见行内评论。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):chunk 7:no typecheck / unit test run in this review worktree (node_modules not installed; NO_TSC). Verified by reading.;You are review agent reverse-audit — Reverse audit agen...:none — all checks above completed within budget.;You are review agent reverse-audit — Reverse audit agen...:none — all checks completed within budget.;chunk 2:did not execute converter.test.ts — the review worktree has no node_modules and a full monorepo npm install was disproportionate to the soft tool budget; …;PR #8260 replaces geminiChat.ts's merge-all/keep-first-si...:none — all planned checks completed within budget.,另有 6 条。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +14405 to +14408
const parts =
chat.getHistory()[chat.getHistory().length - 1]!.parts ?? [];
const signed = parts.find((p) => p.thought && p.thoughtSignature);
expect(signed?.thoughtSignature).toBe('sig-kept');

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 recovery-coalescing keep-test asserts only the presence of the signed episode and the functionCall, never their order — while this same diff pins findIndex(thought) < findIndex(functionCall) in the parallel XML-recovery tests as the replay-load-bearing invariant. Probe-verified at this commit: swapping appendRecoveryContinuationParts's merge order to [...nextParts, ...mergedParts] leaves both coalescing-site tests green; adding the order assertion below turns this keep-test red under the same mutation.

Failure scenario: a regression in appendRecoveryContinuationParts/coalesceRecoveryPairs that lands the continuation's functionCall parts ahead of the truncated turn's signed episode passes this diff's own tests for that site, yet produces a turn a signature-validating provider rejects on replay. Mutation-coverage gap, not a live bug — the current order is correct.

Suggested addition:

expect(parts.findIndex((p) => p.thought)).toBeLessThan(
  parts.findIndex((p) => p.functionCall),
);
中文说明

recovery-coalescing 的 keep 测试只断言带签名 episode 与 functionCall 的存在,从未断言二者的顺序——而本 diff 在平行的 XML 恢复测试中却把 findIndex(thought) < findIndex(functionCall) 作为重放时承重的不变量钉住。已在本 commit 上用探针验证:把 appendRecoveryContinuationParts 的合并顺序换成 [...nextParts, ...mergedParts],两个 coalescing 调用点测试仍然全绿;补上上面的顺序断言后,同一变异会让本 keep 测试变红。

失败场景:appendRecoveryContinuationParts/coalesceRecoveryPairs 的回归若让续轮的 functionCall 部件落在被截断轮次的带签名 episode 之前,本 diff 自己的测试会全部通过,但产出的轮次会被校验签名的提供方在重放时拒绝。这是变异覆盖缺口,不是线上 bug——当前实现的顺序是正确的。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b.

Reproduced your probe exactly: swapping appendRecoveryContinuationParts's concat to [...nextParts, ...mergedParts] left the coalescing-site tests green (6 passed) and the whole output token recovery describe green (34 passed). With the order assertion added, the same mutation fails it — AssertionError: expected 1 to be less than 0.

Added your suggested assertion plus a comment naming the mutant, matching the convention in the parallel XML-recovery keep test.

expect(blocks[2]?.type).toBe('tool_use');
});

it('ensureLeadingAssistantThinking relocates the first thinking run to the front of the latest assistant message', () => {

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] None of the 8 ensureLeadingAssistantThinking test sites feeds the function a tool_use-bearing assistant message with NO thinking block — the runStart === -1 branch is protected only by the guard, and the function's doc names it as a contract ("nothing is fabricated when the message has no thinking block at all"). Probe-verified at this commit: with the if (runStart === -1) continue; guard removed, a no-thinking fixture crashes with a TypeError (reading .type of blocks[-1] in the run-extension loop) while all 111 existing converter tests stay green.

Failure scenario: the shape is reachable in manual mode — dropUnsignedThinkingFromAssistantMessages strips every unsigned thinking block from a completed mid-history tool_use turn (it throws only for the still-active trailing chain), and a model may legitimately return a tool round with no thinking at all. A future refactor dropping the guard would then crash every such request.

Suggested fixture (same style as the sibling tests): contents user 'Hi' → model [{text 'no thinking here'}, {functionCall t1}] → user [functionResponse t1], with ensureLeadingAssistantThinking: true; assert the assistant content stays exactly [{type:'text'}, {type:'tool_use'}].

中文说明

8 个 ensureLeadingAssistantThinking 测试站点中,没有任何一个向该函数传入"带 tool_use 但完全没有 thinking 块"的 assistant 消息——runStart === -1 分支只由守卫保护,而函数文档明确把它列为契约("消息完全没有 thinking 块时不伪造任何块")。已在本 commit 上用探针验证:删除 if (runStart === -1) continue; 守卫后,无 thinking 的 fixture 会在 run 扩展循环里读取 blocks[-1].type 而抛出 TypeError,而现有 111 个 converter 测试全部保持绿色。

失败场景:该形状在 manual 模式下可达——dropUnsignedThinkingFromAssistantMessages 会把历史中段已完成 tool_use 轮次的所有无签名 thinking 块剥掉(只对仍在活动工具链尾部的轮次抛错),且模型完全可能返回不带任何 thinking 的工具轮。未来若有重构删掉该守卫,此类请求将全部崩溃。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b.

Verified your probe: with if (runStart === -1) continue; removed, all 111 existing converter tests still pass. The new test fails under that mutation with TypeError: Cannot read properties of undefined (reading 'type') at converter.ts:1516isThinking(blocks[runEnd]!) with runEnd === -1 reading blocks[-1], precisely as you described.

Used your fixture shape; asserts the assistant content stays exactly ['text', 'tool_use'] with nothing fabricated.

Comment on lines +14357 to +14359
it('keeps a SIGNED trailing reasoning episode on the truncated turn when coalescing recovery pairs', async () => {
// Complement to the drop test above, and the direction that site was
// missing: the XML-recovery call site pins both directions, but this

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 coalesce call site's drop gate — dropDanglingUnsignedTrailingThought(precedingModel.parts, (modelContinuation.parts ?? []).some((p) => p.functionCall)) (geminiChat.ts:5321) — has no negative-control test: neither new coalescing test exercises a continuation WITHOUT a functionCall, i.e. the branch where the unsigned trailing episode must be KEPT. Probe-verified at this commit: replacing the gate argument with true pops the episode while the entire 34-test output-token-recovery describe stays green except the probe.

Failure scenario: successfulRecoveries++ runs for any fully-successful recovery iteration and coalesceRecoveryPairs runs whenever it is > 0, so a plain-text STOP continuation after a truncated mid-episode turn is a real shape; an unconditional-drop mutation would silently delete the truncated turn's unsigned reasoning episode from durable history, violating the keep-when-no-tool-call invariant the per-stream call site establishes.

Suggested complement test: streams [MAX_TOKENS plain text, MAX_TOKENS unsigned thought episode, STOP plain-text continuation (no functionCall)]; assert the final merged entry still contains the unsigned episode and has no functionCall.

中文说明

coalesce 调用点的 drop 门控——dropDanglingUnsignedTrailingThought(precedingModel.parts, (modelContinuation.parts ?? []).some((p) => p.functionCall))(geminiChat.ts:5321)——缺少反向对照测试:两个新的 coalescing 测试都没有覆盖"续轮不含 functionCall"的情形,也就是必须保留无签名尾部 episode 的分支。已在本 commit 上用探针验证:把门控参数直接替换为 true 会弹出该 episode,而整个 34 个测试的 output-token-recovery describe 除探针外全部保持绿色。

失败场景:任何完全成功的恢复迭代都会 successfulRecoveries++,且只要其 > 0 就会执行 coalesceRecoveryPairs,因此"episode 中途截断后接纯文本 STOP 续轮"是真实形状;无条件 drop 的变异会从持久化历史中静默删除被截断轮次的无签名推理 episode,违背 per-stream 调用点所确立的"无工具调用则保留"不变量。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b. This was the sharpest of the six — thank you.

Reproduced independently: hardcoding the gate argument to true pops the episode while all 34 tests in the describe stay green. With the new negative control added, that mutation gives 1 failed | 34 passed — only the new test fails, which is exactly the shape that proves the false branch was previously unpinned.

Added keeps a dangling unsigned trailing episode when coalescing recovery pairs and the continuation calls NO tool, using your suggested stream sequence ([MAX_TOKENS text, MAX_TOKENS unsigned thought, STOP plain-text continuation]), asserting the unsigned episode survives and no functionCall is present.

// in it; adaptive thinking relaxes this. Applied to every such turn
// in history, not just the latest -- see
// ensureLeadingAssistantThinking's doc in the converter.
ensureLeadingAssistantThinking: thinking?.type === 'enabled',

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 gate is pinned by tests for only one of the two buildThinkingConfig paths that return {type:'enabled'} — the explicit-budget_tokens escape hatch (the claude-opus-4-6 fixture). The effort-ladder path (pre-4.6 Claude ids and unversioned proxy ids; the existing 'keeps the budget_tokens config for older 4.x models' test confirms that shape) has no test asserting the leading-thinking normalization is actually applied. Probe-verified at this commit: deriving the gate from the escape-hatch input (reasoning?.budget_tokens !== undefined && !modelRejectsManualThinking()) instead of the built config leaves all 229 generator+converter tests green, while a ladder-mode probe (claude-opus-4-5, effort only) shows normalization disabled — a text-leading tool_use turn ships unreordered.

Failure scenario: such a refactor would silently disable normalization for manual-mode ladder models — the generation where the manual leading-thinking wire rule originated — and since this PR's removal of the parts[0] hoist makes text-before-thinking tool turns reachable, those turns would 400. Mutation-coverage gap, not a live bug — the shipped gate is correct.

Suggested fix: add a third generator-level test mirroring the manual-mode one with a pre-4.6 model (e.g. claude-opus-4-5) and reasoning effort only (no budget_tokens), asserting the merged latest assistant message still leads with the thinking block.

中文说明

该门控只有"显式 budget_tokens 逃生通道"这一条 buildThinkingConfig 返回 {type:'enabled'} 的路径被测试钉住(claude-opus-4-6 fixture)。另一条 effort 阶梯路径(4.6 之前的 Claude 型号与无版本号的代理型号;现有测试 'keeps the budget_tokens config for older 4.x models' 已确认该形状)没有任何测试断言 leading-thinking 归一化确实生效。已在本 commit 上用探针验证:把门控改为基于逃生通道输入(reasoning?.budget_tokens !== undefined && !modelRejectsManualThinking())而非构建出的 config 时,全部 229 个 generator+converter 测试保持绿色,而阶梯模式探针(claude-opus-4-5、仅 effort)显示归一化被关闭——text 打头的 tool_use 轮次未经重排就发出去了。

失败场景:这样的重构会静默关闭 manual 模式阶梯型号的归一化——而 manual leading-thinking 链路规则正是起源于这一代型号;且本 PR 移除了 parts[0] 提升逻辑后,"先文本后思考"的工具轮次已经可达,这些轮次会收到 400。这是变异覆盖缺口,不是线上 bug——当前发布的门控是正确的。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b.

Applied your exact gate mutation (deriving from reasoning?.budget_tokens !== undefined && !modelRejectsManualThinking() instead of the built config): the existing suite stays green, and the new ladder-mode test fails — 118 passed | 1 failed, the single failure being the new test. That matches your claim that normalization is silently disabled for effort-ladder models while everything else passes.

Added a third generator-level test using claude-opus-4-5 with reasoning effort only and no budget_tokens, asserting the built config is {type:'enabled', budget_tokens:32_000} and the merged latest assistant still leads with the thinking block.

Comment on lines +3050 to +3055
const latestAssistant = assistantMessages.at(-1)!;
expect(latestAssistant.content.map((b) => b.type)).toEqual([
'text',
'thinking',
'tool_use',
]);

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 adaptive-mode fixture ends on a model turn with an unanswered functionCall t1: stripTrailingAssistantPrefill keeps the trailing assistant and appends a 'Continue.' user turn, so the converted request carries tool_use t1 followed by a text-only user message — the exact shape mergeConsecutiveAssistantMessages's own doc block quotes as HTTP 400 ("tool_use ids were found without tool_result blocks immediately after"); cleanOrphanedToolCalls runs earlier in the pipeline and protects the trailing message. Probe-verified at this commit: the converted messages end [assistant [text, thinking, tool_use t1], user 'Continue.']. Also verified the assertion is not load-bearing on the unrealistic tail — appending the functionResponse for t1 yields an API-valid request whose latest assistant is still ['text','thinking','tool_use'].

Failure scenario: the fixture models a request the real API rejects, passing only because the client is mocked — contradicting the realism standard its sibling manual-mode test documents in this same describe block; it would also resist a legitimate future fix (re-running cleanOrphanedToolCalls after the prefill pass would drop the tool_use and fail this assertion).

Suggested fix: mirror the manual-mode test by appending the functionResponse user turn for t1 after the tool turn; the assertion is unchanged.

中文说明

该 adaptive 模式 fixture 以一个带未应答 functionCall t1 的模型轮次结尾:stripTrailingAssistantPrefill 会保留这条尾部 assistant 消息并追加 'Continue.' 用户轮,于是转换后的请求变成 tool_use t1 后面紧跟一条纯文本用户消息——正是 mergeConsecutiveAssistantMessages 文档块中引用的 HTTP 400 形状("tool_use ids were found without tool_result blocks immediately after");cleanOrphanedToolCalls 在流水线更早处运行,且对尾部消息有保护。已在本 commit 上用探针验证:转换结果以 [assistant [text, thinking, tool_use t1], user 'Continue.'] 结尾。另已验证该断言并不依赖这个不真实的尾部——为 t1 补上 functionResponse 后请求变为 API 合法形状,最新 assistant 仍为 ['text','thinking','tool_use']

失败场景:fixture 模拟的是真实 API 会拒绝的请求,只因客户端被 mock 才通过——与同一 describe 块中 manual 模式兄弟测试明确记录的真实性标准相矛盾;它还会阻碍未来合理的修复(若在 prefill 之后重跑 cleanOrphanedToolCallstool_use 会被清掉,本断言随之失败)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b.

You were right that the assertion doesn't depend on the unrealistic tail. Appended the functionResponse user turn for t1, mirroring the manual-mode sibling: the request now ends on a tool_result, no synthetic 'Continue.' is appended, and the latest assistant is still ['text','thinking','tool_use'].

The assertion is byte-identical — the commit is 293 insertions with zero deletions, so nothing existing was weakened. Also agree on the forward-compatibility point: had cleanOrphanedToolCalls later been re-run after the prefill pass, the old fixture would have started failing for a reason unrelated to what it tests.

expect(firstAssistant(whenPrior)).toEqual(firstAssistant(whenLatest));
});

it('ensureLeadingAssistantThinking relocates a multi-block first thinking run as a single unit', () => {

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] All 8 ensureLeadingAssistantThinking test sites place the first thinking run strictly before the end of the content array; none covers a run that ENDS at the final block, so the runEnd < blocks.length loop bound is pinned by zero tests. Probe-verified at this commit: deleting that bound condition leaves all 229 converter+generator tests green, and a trailing-run fixture crashes with a TypeError (isThinking(blocks[blocks.length]) reads .type of undefined). The trailing-run shape is one this PR itself manufactures: a single STOP stream [text, functionCall, signed episode] now persists in stream order (pre-PR the hoist put the merged blob at parts[0]), and the recovery-coalescing keep path merges into [text…, functionCall, signed episode] — a tool_use-bearing, text-leading message whose first thinking run ends at blocks.length - 1.

Failure scenario: a mutation deleting runEnd < blocks.length && passes the entire suite, then crashes every replayed turn whose first thinking run is trailing — the exact request after the tool result lands.

Suggested fixture: contents user 'Hi' → model [{text 'text A'}, {functionCall t1}] → model [{text 'episode', thought: true, thoughtSignature: 'sE'}] → user [functionResponse t1], with ensureLeadingAssistantThinking: true; assert the assistant content converts to ['thinking', 'text', 'tool_use'].

中文说明

全部 8 个 ensureLeadingAssistantThinking 测试站点都把第一段 thinking run 放在内容数组中部(其后还有非 thinking 块);没有任何一个覆盖"run 恰好结束于最后一个块"的情形,因此 runEnd < blocks.length 循环边界没有任何测试钉住。已在本 commit 上用探针验证:删除该边界条件后全部 229 个 converter+generator 测试保持绿色,而尾部 run fixture 会抛出 TypeError(isThinking(blocks[blocks.length]) 读取 undefined 的 .type)。尾部 run 形状正是本 PR 自己制造的:单条 STOP 流 [text, functionCall, 带签名 episode] 现在按流顺序持久化(本 PR 之前 hoist 会把合并体提到 parts[0]),且 recovery-coalescing 的 keep 路径会合并出 [text…, functionCall, 带签名 episode]——一个带 tool_use、text 打头、且第一段 thinking run 结束于 blocks.length - 1 的消息。

失败场景:删除 runEnd < blocks.length && 的变异能通过整个测试套件,然后在每一次"第一段 thinking run 处于尾部"的重放轮次上崩溃——正是 tool result 返回后的那次请求。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b.

Verified independently: deleting runEnd < blocks.length && leaves the converter suite green, and the new trailing-run test then fails with TypeError: Cannot read properties of undefined (reading 'type')1 failed | 112 passed.

Your point that this PR itself manufactures the shape is the part that made it worth adding: now that episodes persist in stream order rather than being hoisted to parts[0], a first thinking run ending at blocks.length - 1 is reachable both from a single STOP stream and from the recovery-coalescing keep path. Used your fixture; asserts ['thinking', 'text', 'tool_use'].

… unrealistic fixture

Round 5 reported six gaps in this PR's own tests. All six reproduce: in
each case the shipped source is correct, but a mutation that breaks it
left the suite green. Each addition below was verified by mutating the
source, confirming the existing suite stayed green, then confirming the
new test goes red.

geminiChat.test.ts
- The recovery-coalescing keep test asserted the signed episode and the
  functionCall were both present but never that the episode came first.
  Swapping appendRecoveryContinuationParts's concat to
  [...nextParts, ...mergedParts] left both coalescing tests green.
- The coalescing drop gate had no negative control: nothing covered a
  continuation with NO functionCall, the branch where the unsigned
  trailing episode must be kept. Hardcoding that gate argument to `true`
  popped the episode while all 34 tests in the describe stayed green.

converter.test.ts
- No test fed ensureLeadingAssistantThinking a tool_use turn with no
  thinking block, so the `runStart === -1` guard was unpinned. Removing
  it TypeErrors on blocks[-1] while all 111 tests passed.
- No test placed the first thinking run at the end of the content array,
  so the `runEnd < blocks.length` bound was unpinned. Removing it
  TypeErrors on blocks[blocks.length]. This shape is one this PR itself
  makes reachable, now that episodes persist in stream order.

anthropicContentGenerator.test.ts
- buildThinkingConfig reaches {type:'enabled'} two ways, but only the
  explicit budget_tokens escape hatch was covered. Deriving the gate from
  that input instead of the built config silently disables normalization
  for effort-ladder models while all 229 tests pass.
- The adaptive-mode fixture ended on an unanswered functionCall, so the
  converted request carried a tool_use with no tool_result after it --
  the HTTP 400 shape mergeConsecutiveAssistantMessages's own doc quotes.
  Appending the functionResponse makes it API-valid; the assertion is
  unchanged.

571 tests pass across the three suites. No source file is modified.

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

Unresolved, please confirm:

  • [Critical] packages/core/src/core/geminiChat.ts:1096 — round-2 blocker (ledger R2-2): the trailing-only drop still cannot distinguish a truncated signing-provider episode from a non-signing provider's ordinary trailing thought. The author completed the blocker's minimum-action path (the overclaiming doc is corrected and the truncated-all-unsigned shape is pinned by a tripwire test asserting both the history and JSONL surfaces — both verified at HEAD), but that path is conditioned on a maintainer ruling the residual reasoning-loss an acceptable trade-off, and no such ruling is on the PR — the mechanism still fires by design, so it cannot be ruled fixed. Maintainer confirmation needed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI (fork-PR authorization gate) and its E2E suite did not run locally; the unit suites covering every changed file (geminiChat, converter, anthropicContentGenerator) ran green in CI (Linux) and locally, and macOS/Windows unit jobs were likewise skipped.

Not explored to full depth (tool budget reached): You are review agent reverse-audit — Reverse audit agen...: none — all checks above were completed within budget.; You are review agent reverse-audit — Reverse audit agen...: none — all planned checks completed within budget.; You are review agent reverse-audit — Reverse audit agen...: none — all checks I started were completed within budget.; chunk 4: none — all checks I started completed within budget.; You are review agent reverse-audit — Reverse audit agen...: none — all checks I started completed within budget., and 1 more.

中文说明

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI (fork-PR authorization gate) and its E2E suite did not run locally; the unit suites covering every changed file (geminiChat, converter, anthropicContentGenerator) ran green in CI (Linux) and locally, and macOS/Windows unit jobs were likewise skipped。

未探索到全部深度(达到工具调用预算):You are review agent reverse-audit — Reverse audit agen...:none — all checks above were completed within budget.;You are review agent reverse-audit — Reverse audit agen...:none — all planned checks completed within budget.;You are review agent reverse-audit — Reverse audit agen...:none — all checks I started were completed within budget.;chunk 4:none — all checks I started completed within budget.;You are review agent reverse-audit — Reverse audit agen...:none — all checks I started completed within budget.,另有 1 条。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment on lines +5007 to +5009
dropDanglingUnsignedTrailingThought(
consolidatedHistoryParts,
recovery.functionCallParts.some((p) => p.functionCall !== undefined),

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] This XML-recovery call site pops unsigned thought episodes that were not trailing in the stream: the removal loop above splices out every visible text part first, manufacturing trailing-ness. A complete turn [thought(unsigned), text-with-XML] from a non-signing thinking provider (finish reason present, no truncation — this branch's gate requires exactly that) loses its legitimate reasoning from in-memory history, the JSONL record, and --resume replay. The pop has no protective benefit here: non-signing providers never validate signatures, so there is no wedge to prevent, and the signing-provider variant is the false-negative direction the function's own doc already accepts as uncaught. — Failure scenario: probe A/B at this commit: one chunk with parts [{text:'REASONING', thought:true}, {text:'<invoke …>'}], finishReason STOP → PR tree: history AND recorded JSONL are [{functionCall}] (reasoning gone from both durable sinks); base tree: [{text:'REASONING', thought:true}, {functionCall}] preserved; removing only this new drop call restores the base behavior. Identical wire shapes get opposite treatment depending only on whether the tool call arrived structured (the per-stream site preserves the thought) or as XML text (this site deletes it). The pinning test at geminiChat.test.ts:16221 argues the truncated-stream case; a complete turn is the sibling state that rationale does not cover.

中文说明

该 XML 恢复调用点会弹出在流中并不处于尾部的无签名 thought episode:上方的移除循环先把所有可见文本部件移出,人为制造出"尾部"状态。来自无签名 thinking 提供方的一个完整轮次 [thought(无签名), 含XML的文本](有 finish reason、无截断——本分支的触发条件恰恰要求如此)会从内存历史、JSONL 记录以及 --resume 回放中丢失其合法推理内容。此处弹出没有任何保护收益:无签名提供方从不校验签名,不存在需要防止的死锁;而签名提供方的变体则属于函数文档自己已承认为无法捕获的"漏报"方向。— 失败场景:已在本 commit 上用探针 A/B 验证:单个 chunk 部件为 [{text:'REASONING', thought:true}, {text:'<invoke …>'}]、finishReason STOP → 本 PR 树:历史与 JSONL 记录均为 [{functionCall}](推理内容在两个持久化面上都消失);base 树:[{text:'REASONING', thought:true}, {functionCall}] 得以保留;仅移除本新增 drop 调用即可恢复 base 行为。完全相同的线形仅因工具调用是结构化到达(per-stream 调用点保留 thought)还是以 XML 文本到达(本调用点删除 thought)而得到相反处理。geminiChat.test.ts:16221 的钉住测试论证的是截断流场景;完整轮次是该理由未覆盖的同族状态。

Suggested fix (spans two locations, so no one-click suggestion): capture trailing-ness before the removal loop, then gate the pop on it:

// before the textIndices removal loop:
const lastPartBeforeRemoval =
  consolidatedHistoryParts[consolidatedHistoryParts.length - 1];
const hadTrailingDanglingThought = Boolean(
  lastPartBeforeRemoval?.thought &&
    lastPartBeforeRemoval.text &&
    !lastPartBeforeRemoval.thoughtSignature,
);
// ...removal loop unchanged...
// replace the unconditional drop with:
if (hadTrailingDanglingThought) {
  dropDanglingUnsignedTrailingThought(consolidatedHistoryParts, true);
}

A truncation-dangling episode is trailing before removal by definition, so every documented protection is preserved while removal-exposed episodes are spared. The pinning test at geminiChat.test.ts:16221 needs updating accordingly, plus a complement test asserting a complete-turn [thought(unsigned), XML-text] keeps its reasoning.

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +5143 to +5147
const insertAt = consolidatedHistoryParts.findIndex(
(part) => !part.thought,
);
consolidatedHistoryParts.splice(
insertAt < 0 ? consolidatedHistoryParts.length : insertAt,

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] This transport-continuation prefix insertion appends the prefix after a surviving trailing unsigned dangling thought episode: when only thought parts remain, findIndex((part) => !part.thought) is −1 and the prefix lands at the end, burying the episode mid-array before coalesceRecoveryPairs runs. The coalescing-site trailing-only drop then no-ops (the trailing part is now text), and even a single recovery pair yields [thought(unsigned), text, functionCall] — the exact shape the three new drop call sites exist to prevent. — Failure scenario: probe-verified chain at this commit: an SSE cut schedules a transport continuation → the continuation attempt truncates at MAX_TOKENS mid-episode with no visible text → parts [thought(unsigned)] (per-stream drop no-ops: hasToolCall false) → this branch appends the prefix after the episode → models already at the output ceiling take the no-op-escalation branch into recovery → the recovery continuation brings text + functionCall; the coalescing-site drop sees text trailing and no-ops → the tool result lands, the turn enters the active tool-use chain, and dropUnsignedThinkingFromAssistantMessages throws "Anthropic-compatible proxy omitted the thinking signature for a tool-use turn that is still in progress" on every subsequent request — a permanent wedge. Flip-verified: reverting this branch to the pre-PR unshift leaves the episode trailing and the PR's own coalescing-site drop pops it. (Base produced the same final shape, so this is a live wedge the new protection misses on this input path, not a regression vs base.)

中文说明

该 transport-continuation 前缀插入会把前缀文本追加到幸存的尾部无签名悬空 thought episode 之后:当仅剩 thought 部件时,findIndex((part) => !part.thought) 为 −1,前缀落在数组末尾,使 episode 在 coalesceRecoveryPairs 运行之前就被埋入数组中部。coalescing 调用点的 trailing-only drop 随后失效(此时尾部部件已是文本),即使只有一个 recovery 配对也会产生 [thought(无签名), text, functionCall] —— 正是三个新 drop 调用点本要防止的形状。— 失败场景:已在本 commit 上用探针验证完整链路:SSE 断流触发 transport continuation → continuation 尝试在 episode 中途因 MAX_TOKENS 截断且无可见文本 → 部件为 [thought(无签名)](per-stream drop 因 hasToolCall 为 false 而失效)→ 本分支把前缀追加到 episode 之后 → 已处于输出上限的模型走 no-op-escalation 分支进入 recovery → recovery continuation 带回 text + functionCall;coalescing 调用点的 drop 看到尾部是文本而失效 → tool result 返回后该轮进入活动工具链,dropUnsignedThinkingFromAssistantMessages之后每次请求都抛出 "Anthropic-compatible proxy omitted the thinking signature for a tool-use turn that is still in progress" —— 会话永久卡死。已用 flip 验证:把本分支还原为 PR 之前的 unshift,episode 保持尾部位置,PR 自己的 coalescing 调用点 drop 会将其弹出。(base 对同样输入产生相同最终形状,因此这是新保护机制在此输入路径上漏掉的现存死锁,而非相对 base 的回归。)

Suggested change
const insertAt = consolidatedHistoryParts.findIndex(
(part) => !part.thought,
);
consolidatedHistoryParts.splice(
insertAt < 0 ? consolidatedHistoryParts.length : insertAt,
dropDanglingUnsignedTrailingThought(consolidatedHistoryParts, true);
const insertAt = consolidatedHistoryParts.findIndex(
(part) => !part.thought,
);
consolidatedHistoryParts.splice(
insertAt < 0 ? consolidatedHistoryParts.length : insertAt,

Once text follows the episode, the coalescing-site trailing-only check can never protect it again — drop it here, accepting the same documented false-positive trade-off the other call sites accept. Add a regression test: transport cut → mid-episode MAX_TOKENS continuation → recovery with functionCall, asserting no unsigned thought remains and the next conversion does not throw.

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment on lines +5163 to +5171
contentText = consolidatedHistoryParts
.filter((part) => part.text)
.filter(isVisibleTextPart)
.map((part) => part.text)
.join('')
.trim();
}
if (
willPersistToHistory &&
(thoughtContentPart || contentText || hasToolCall || usageMetadata)
(consolidatedHistoryParts.length > 0 || usageMetadata)

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 transport-continuation contentText recompute lost its last consumer to this same hunk: pre-PR it fed the recording gate (... || contentText || ...) and the record message (...(contentText ? [{ text: contentText }] : [])); both were replaced by the consolidatedHistoryParts-based equivalents below. An exhaustive grep confirms zero reads of contentText after this point (the XML gate, the recovery debug log, and the validation all precede it; the XML-branch recompute at ~5022 keeps its own readers). — Concrete cost: every transport-continuation turn pays a filter/map/join/trim whose result is discarded, and the vestigial recompute sits directly above the recording block beside the drift comment that still discusses contentText/record coupling — inviting a future maintainer editing the record path to assume contentText still shapes the record.

中文说明

该 transport-continuation 分支中的 contentText 重算被同一个 hunk 夺走了最后一个消费者:PR 之前它为记录门控(... || contentText || ...)和记录消息(...(contentText ? [{ text: contentText }] : []))提供数据;两者都已被下方基于 consolidatedHistoryParts 的等价逻辑取代。全量 grep 确认此点之后 contentText 的读取次数为零(XML 门控、recovery 调试日志与校验都在它之前;XML 分支约 5022 行处的重算仍有自己的读者)。— 具体代价:每个 transport-continuation 轮次都会白白执行一次 filter/map/join/trim,且这段残留的重算就摆在记录块上方、紧邻仍在讨论 contentText/记录耦合的 drift 注释——容易让未来修改记录路径的维护者误以为 contentText 仍在塑造记录。

Suggested change
contentText = consolidatedHistoryParts
.filter((part) => part.text)
.filter(isVisibleTextPart)
.map((part) => part.text)
.join('')
.trim();
}
if (
willPersistToHistory &&
(thoughtContentPart || contentText || hasToolCall || usageMetadata)
(consolidatedHistoryParts.length > 0 || usageMetadata)
}
if (
willPersistToHistory &&
(consolidatedHistoryParts.length > 0 || usageMetadata)

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment on lines +5177 to +5184
message: consolidatedHistoryParts.map((part) =>
// Non-null: redactStructuredOutputArgsForRecording only returns
// null for parts with no functionCall, which this ternary
// already excludes.
part.functionCall
? redactStructuredOutputArgsForRecording(part)!
: part,
),

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 record mapping collapses any part carrying functionCall to {functionCall} only — redactStructuredOutputArgsForRecording returns {functionCall} without spreading the input — silently dropping every sibling field (text, thoughtSignature) from the JSONL record while this.history.push keeps the raw part. That reintroduces exactly the record/history drift this hunk's merge-in-one-place comment claims to eliminate, for combined text+functionCall parts — a spec-violating wire shape the codebase's own defensive predicates treat as real (isValidNonThoughtTextPart: "Technically, the model should never generate parts that have text and any of these but we don't trust them so check anyways"). — Failure scenario: probe at this commit: a model part {text:'visible model text', functionCall:{…}} → history keeps [{text, functionCall}], recorded JSONL message is [{functionCall}] — visible in the live session, permanently absent on --resume; a co-located thoughtSignature drops by the same mechanism. Pre-PR the text was preserved (contentText's filter included combined parts); the flip run (spread alongside the redacted call) restores parity. Trigger requires a spec-violating backend shape, hence Suggestion — but it cuts directly against this PR's stated --resume-fidelity intent.

中文说明

该记录映射会把任何携带 functionCall 的部件折叠为仅 {functionCall} —— redactStructuredOutputArgsForRecording 返回 {functionCall} 且不展开输入部件 —— 从而静默丢弃该部件上的所有兄弟字段(textthoughtSignature),而 this.history.push 保留原始部件。对于 text+functionCall 组合部件,这恰好重新引入了本 hunk "一处合并"注释声称要消除的 记录/历史 漂移 —— 而这种违反规范的线形正是代码库自己的防御性断言当作真实存在的(isValidNonThoughtTextPart:"Technically, the model should never generate parts that have text and any of these but we don't trust them so check anyways")。— 失败场景:已在本 commit 上用探针验证:模型部件 {text:'visible model text', functionCall:{…}} → 历史保留 [{text, functionCall}],而记录的 JSONL 消息为 [{functionCall}] —— 实时会话可见、--resume 后永久缺失;同位的 thoughtSignature 以同样机制丢失。PR 之前文本是保留的(contentText 的过滤器包含组合部件);flip 运行(在脱敏结果旁展开原部件)可恢复一致。触发需要违反规范的后端形状,故为 Suggestion —— 但它与本 PR 声明的 --resume 保真目标直接相悖。

Suggested change
message: consolidatedHistoryParts.map((part) =>
// Non-null: redactStructuredOutputArgsForRecording only returns
// null for parts with no functionCall, which this ternary
// already excludes.
part.functionCall
? redactStructuredOutputArgsForRecording(part)!
: part,
),
message: consolidatedHistoryParts.map((part) =>
// Non-null: redactStructuredOutputArgsForRecording only returns
// null for parts with no functionCall, which this ternary
// already excludes.
part.functionCall
? { ...part, ...redactStructuredOutputArgsForRecording(part)! }
: part,
),

The spread order keeps the redacted functionCall authoritative while retaining text/thoughtSignature in the record.

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +3624 to +3628
const history = chat.getHistory();
expect(history[1].parts).toEqual([
{ text: 'visible reasoning', thought: true, thoughtSignature: 'sig1' },
{ functionCall: { id: 'call1', name: 'tool', args: {} } },
{ text: '', thought: true, thoughtSignature: 'sig2' },

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 test (should still record a mid-turn signature-only reasoning episode) asserts only the in-memory history surface — it uses the non-recording chat and never inspects recordAssistantTurn — leaving the signature-only episode's JSONL surface unpinned anywhere in the file (the {text:'', thought:true, thoughtSignature} shape appears in exactly two history-only assertions), contrary to the dual-surface convention this same PR establishes elsewhere: the interleaved-episodes recording test (~3141) and the accepted-false-positive tripwire (~3633) both assert history and recordAssistantTurn.mock.calls[0]?.[0].message on purpose. — Failure scenario: probe flip at this commit: applying the hypothetical regression — a truthiness gate .filter((part) => part.text) on the record path — a dual-surface version of this test FAILS with the recorded message missing the {text:'', thought:true, thoughtSignature:'sig2'} episode while this history-only assertion stays green — exactly the class the interleaved-recording test's comment warns about ("would keep every history-only assertion above green while silently losing every thoughtSignature on --resume replay"). The empty-text episode is the most filter-prone part in the block. Distinct from the open round-4 suggestion at geminiChat.test.ts:3257, which scopes itself to the two drop-path tests.

中文说明

该测试(should still record a mid-turn signature-only reasoning episode)只断言了内存历史面 —— 它使用不带录制回调的 chat,从不检查 recordAssistantTurn —— 使得"纯签名 episode"的 JSONL 面在整个文件中没有任何钉住({text:'', thought:true, thoughtSignature} 形状恰好只出现在两处仅断言历史的断言中),这与本 PR 在别处自己建立的双面约定相悖:interleaved-episodes 录制测试(约 3141 行)与 accepted-false-positive 触发器测试(约 3633 行)都刻意同时断言历史 recordAssistantTurn.mock.calls[0]?.[0].message。— 失败场景:已在本 commit 上用探针 flip 验证:施加假想回归 —— 在记录路径上加真值门控 .filter((part) => part.text) —— 该测试的双面版本会失败(记录消息缺少 {text:'', thought:true, thoughtSignature:'sig2'} episode),而当前仅断言历史的断言依然绿色 —— 正是 interleaved 录制测试注释所警告的类别("would keep every history-only assertion above green while silently losing every thoughtSignature on --resume replay")。空文本 episode 是该块中最容易被真值过滤掉的部件。与 round-4 在 geminiChat.test.ts:3257 的未决建议不同:那一条仅覆盖两条 drop 路径测试。

Suggested fix — mirror the records interleaved reasoning episodes in the JSONL turn pattern: construct the chat with a recordAssistantTurn spy and additionally assert the recorded message equals the same four-part array:

expect(recordAssistantTurn.mock.calls[0]?.[0].message).toEqual([
  { text: 'visible reasoning', thought: true, thoughtSignature: 'sig1' },
  { functionCall: { id: 'call1', name: 'tool', args: {} } },
  { text: '', thought: true, thoughtSignature: 'sig2' },
  { functionCall: { id: 'call2', name: 'tool', args: {} } },
]);

— qwen3.8-max via Qwen Code /review (v0.21.10)

…nd add a fourth call site

Round-6 review found two Criticals in dropDanglingUnsignedTrailingThought's
call sites, both probe-verified:

- The XML-recovery call site (third site) popped unsigned thought episodes
  that were only ARTIFICIALLY trailing: its own removal loop splices out
  every non-thought text part first, which can manufacture a trailing
  position for an episode that was never trailing in the actual stream. A
  complete, untruncated turn from a non-signing provider that emitted XML
  tool-calls (finish reason present, no truncation) lost its legitimate
  reasoning from history, the JSONL record, and --resume replay for no
  protective benefit. Fixed by capturing trailing-ness BEFORE the removal
  loop and gating the pop on it.

- The transport-continuation prefix insertion had no drop call at all: when
  only thought parts remained, the prefix landed after them, burying a
  trailing dangling episode mid-array before the coalescing-site
  trailing-only check could ever run -- reintroducing the exact wedge the
  other three call sites exist to prevent. Fixed by adding a fourth call
  site immediately before that insertion, accepting the same documented
  false-positive trade-off the other sites already accept.

Updated the two existing tests that pinned the old (buggy) behavior at
each site to assert the corrected behavior instead, and updated the
function's doc comment to describe all four call sites.
@netbrah

netbrah commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the round-6 pass — both probe-verified failure scenarios were real.

Fixed the two new Criticals in 6b3e68a

R6-1 (geminiChat.ts, XML-recovery call site): the removal loop spliced out every non-thought text part before the trailing-only check ran, which could manufacture a trailing position for an episode that was never trailing in the actual stream. A complete, untruncated turn from a non-signing provider ending in [thought(unsigned), text-with-XML] lost real reasoning from history, the JSONL record, and --resume for no protective benefit. Fixed by capturing trailing-ness before the removal loop and gating the pop on that captured value, matching the suggested fix.

R6-2 (transport-continuation prefix insertion): there was no drop call at this site at all. When only thought parts remained, the prefix landed after them, burying a trailing dangling episode mid-array before the coalescing-site trailing-only check could ever run — reintroducing the exact wedge the other three call sites exist to prevent. Added the fourth call site immediately before the insertion, accepting the same documented false-positive trade-off the other sites already accept.

Updated the two existing tests that had been pinning the old (buggy) behavior at each site to assert the corrected behavior instead, and updated the function's doc comment to describe all four call sites. Full geminiChat.test.ts suite green (339 tests), plus client.test.ts and coreToolScheduler.test.ts (1017 tests total) for adjacent coverage.

Requesting a maintainer ruling — geminiChat.ts:1096 (tracked as R2-2 since round 2)

This is the one item in the review I can't resolve with more code, because it's a genuine trade-off, not a bug: dropDanglingUnsignedTrailingThought's trailing-only check cannot distinguish, from the wire shape alone:

  • A truncated signing-provider (Anthropic-family) episode — an unsigned trailing thought that never got its terminating signature because the stream cut off mid-episode. Pairing this with a tool call permanently wedges the session on every subsequent request (dropUnsignedThinkingFromAssistantMessages throws, or native Anthropic rejects outright).
  • A non-signing provider's (e.g. DeepSeek) ordinary, complete trailing thought — DeepSeek doesn't validate thinking signatures, so an unsigned trailing thought after a tool call is normal, valid wire shape for that provider, not a corruption signal.

Both end in the exact same shape: [..., thought(unsigned, no signature)] before/at a tool call. Gating the pop on "does this turn have a signature anywhere" was evaluated and rejected — it's wrong at the recovery-coalescing call site, where a genuinely truncated turn legitimately has no signature anywhere yet.

So the function currently always drops in this ambiguous case, on the reasoning that losing a trailing reasoning fragment for a provider that never validates signatures is cheaper than permanently wedging a session that does. That's documented in the function's doc comment (and has been the accepted policy at the other three call sites since earlier rounds), but a maintainer ruling on whether this specific trade-off is acceptable for merge has never landed on the PR.

Ask: @wenshao @qqqys — is the false-positive direction (a non-signing provider occasionally losing a trailing reasoning fragment) an acceptable cost to prevent the false-negative direction (a signing provider's session getting permanently wedged)? If not, I'm open to alternatives (e.g. a provider/model-family hint threaded down to this check), but every alternative I've evaluated so far either reopens the wedge in some other path or requires plumbing that doesn't exist yet at this layer. Happy to scope that as follow-up work if the current trade-off isn't acceptable for this PR.

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

Partially reviewed — gaps disclosed. Suggestions are inline.

Unresolved, please confirm:

  • [Critical] packages/core/src/core/geminiChat.ts:1104 — round-2 blocker (ledger R2-2): the trailing-only drop still cannot distinguish a truncated signing-provider episode from a non-signing provider's ordinary trailing thought. The author completed the blocker's minimum-action path (the overclaiming doc is corrected and the truncated-all-unsigned shape is pinned by a tripwire test asserting both the history and the JSONL surfaces — both verified at HEAD), but that path is conditioned on a maintainer ruling the residual reasoning-loss an acceptable trade-off, and no such ruling is on the PR — the mechanism still fires by design, so the blocker cannot be ruled fixed. Maintainer confirmation needed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI (fork-PR authorization gate) and its E2E suite did not run locally; the unit suites covering every changed file ran green in CI (Linux) and locally.

Not reviewed: reverse audit — reached the 5-round cap without two consecutive dry rounds (rounds 4 and 5 both reported findings; every chunk was audited in each round it was not retired, and the capped round's finding was verified).

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI (fork-PR authorization gate) and its E2E suite did not run locally; the unit suites covering every changed file ran green in CI (Linux) and locally。

未审查:reverse audit — reached the 5-round cap without two consecutive dry rounds (rounds 4 and 5 both reported findings; every chunk was audited in each round it was not retired, and the capped round's finding was verified)。

— qwen3.8-max via Qwen Code /review (v0.21.12)

Comment on lines +5179 to +5180
dropDanglingUnsignedTrailingThought(consolidatedHistoryParts, true);
// Continuation returned no visible text of its own (e.g. only a

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] R7-2: This fourth dropDanglingUnsignedTrailingThought call site hardcodes the gate to true, so it pops an unsigned trailing episode even when the turn contains no functionCall and none can ever be attached later — a case the pre-PR code preserved. The MAX_TOKENS recovery loop is the only mechanism that ever attaches a functionCall to an already-pushed turn (it is gated on lastFinishReason === FinishReason.MAX_TOKENS and breaks when the turn already carries a functionCall), so on a continuation that completes with STOP and thought-only output the drop protects nothing. — Failure scenario: transport cut after delivered text (the #8094 continuation path); the continuation yields thought-only output and finishes STOP with no tool call anywhere → the legitimate reasoning fragment is dropped from both in-memory history and the JSONL record (--resume loses it permanently) with no wedge-prevention benefit — exceeding the function's documented false-positive trade-off, which is scoped to truncation after a tool call. The pinned test at geminiChat.test.ts:9292 asserts exactly this loss under a STOP/no-functionCall fixture.

Witness: geminiChat.ts:5179 dropDanglingUnsignedTrailingThought(consolidatedHistoryParts, true); vs the pinned test assertion parts: [{ text: 'part one ' }] (green) under a finishReason: 'STOP' fixture with no functionCall; recovery gate quoted at geminiChat.ts:3455 lastFinishReason === FinishReason.MAX_TOKENS &&.

Suggested fix: gate the pop on the real hazard, e.g. dropDanglingUnsignedTrailingThought(consolidatedHistoryParts, hasToolCall || deferredFinishReason === FinishReason.MAX_TOKENS); — only a MAX_TOKENS finish can route this turn through coalesceRecoveryPairs later. Alternatively, if the unconditional drop is deliberate, extend this site's comment and the pinned test's title to name the no-tool-call over-drop as an accepted loss, making the asymmetry with the coalescing-site negative control explicit.

中文说明

第四个 dropDanglingUnsignedTrailingThought 调用点把门控参数硬编码为 true,因此即使本轮不包含任何 functionCall、且之后也不可能有机制再附加 functionCall,它仍会弹出无签名的尾部 episode —— 而 PR 之前的代码在这种情况下是保留它的。MAX_TOKENS 恢复循环是唯一能向已推入历史的轮次附加 functionCall 的机制(它以 lastFinishReason === FinishReason.MAX_TOKENS 为门控,且轮次已有 functionCall 时立即 break),因此对于以 STOP 正常结束、只有 thought 输出的 continuation,这个 drop 没有任何保护作用。— 失败场景:已交付文本后发生传输中断(#8094 continuation 路径),continuation 只产出 thought 并以 STOP 结束、全程无工具调用 → 合法的推理片段同时从内存历史和 JSONL 记录中丢失(--resume 永久丢失),却没有任何防死锁收益 —— 超出了该函数文档中假阳性权衡的范围(该权衡仅限于"工具调用之后被截断")。geminiChat.test.ts:9292 的钉住测试恰恰在 STOP/无 functionCall 的 fixture 下断言了这一丢失。

建议修复:用真实风险门控该弹出,例如 hasToolCall || deferredFinishReason === FinishReason.MAX_TOKENS —— 只有 MAX_TOKENS 结束才可能让本轮稍后进入 coalesceRecoveryPairs。若该无条件 drop 是有意为之,请在本调用点注释和钉住测试标题中明确说明"无工具调用时的过度删除属于已接受的损失",使其与 coalescing 调用点的负对照保持显式一致。

— qwen3.8-max via Qwen Code /review (v0.21.12)

Comment on lines 9329 to 9332
expect(chat.getHistory().at(-1)).toEqual({
role: 'model',
parts: [
{ text: 'only thinking', thought: true },
{ text: 'part one ' },
],
parts: [{ text: 'part one ' }],
});

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] R7-3: The transport-continuation drop site (fourth dropDanglingUnsignedTrailingThought call site) is tested only in its unsigned-pop direction; no test pins that a SIGNED trailing episode survives this site with the prefix inserted after it. The keep direction is pinned for the XML-recovery site ("keeps a SIGNED trailing reasoning episode when XML tool call recovery fires") and the coalescing site, but not here. — Failure scenario: mutation — replace the gated call with an unconditional pop of any trailing thought part (drop the !lastPart.thoughtSignature condition): every test that reaches this site uses unsigned fixtures, so the suite stays 339/339 green while a completed, signed, replayable episode from a thought-only continuation is silently deleted from the active turn — a signature-validating provider then rejects replay of that turn.

Witness: mutation run: 339 passed (339) — same as baseline; a canary test with a signed thought-only continuation fails under the mutation (received parts: [{text:'part one '}], signed episode gone) and passes under the real code.

Suggested fix: add a sibling test in this describe block — the continuation yields { text: 'reasoning', thought: true } followed by { thought: true, thoughtSignature: 'sig' } (no text part), and assert history keeps { text: 'reasoning', thought: true, thoughtSignature: 'sig' } with the delivered prefix inserted AFTER it.

中文说明

transport-continuation 的 drop 调用点(第四个 dropDanglingUnsignedTrailingThought 调用点)只测试了"弹出无签名 episode"方向;没有任何测试钉住"带签名的尾部 episode 在该调用点幸存、且前缀文本插入其后"。保持方向在 XML 恢复调用点("keeps a SIGNED trailing reasoning episode when XML tool call recovery fires")和 coalescing 调用点都有钉住测试,唯独这里没有。— 失败场景:变异 —— 把带门控的调用替换为无条件弹出任意尾部 thought 部件(去掉 !lastPart.thoughtSignature 条件):所有能到达该调用点的测试都使用无签名 fixture,因此整个套件仍然 339/339 全绿,而一个来自纯 thought continuation 的、已完成且可回放的带签名 episode 会被静默删除 —— 校验签名的提供方随后会拒绝回放该轮。

建议修复:在本 describe 块补一个同族测试 —— continuation 先产出 { text: 'reasoning', thought: true },再产出 { thought: true, thoughtSignature: 'sig' }(无文本部件),断言历史保留 { text: 'reasoning', thought: true, thoughtSignature: 'sig' },且已交付的前缀文本插入在其后。

— qwen3.8-max via Qwen Code /review (v0.21.12)

Comment on lines 5204 to 5206
contentText = consolidatedHistoryParts
.filter((part) => part.text)
.filter(isVisibleTextPart)
.map((part) => part.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] R6-3: Round-6 ledger finding, still standing at this commit (re-verified; open round-6 thread had no reply). This transport-continuation contentText recompute lost its last consumer to this same PR's recording rewrite: pre-PR it fed the recording gate (...(contentText ? [{ text: contentText }] : [])) and the (thoughtContentPart || contentText || hasToolCall || usageMetadata) disjunct; both readers are deleted and recording now reads consolidatedHistoryParts directly. Every remaining contentText read (XML-recovery gate 4969-4972, debug logs 5071/5075, hasAnyContent 5093, lacksVisibleToolResultProgress 5096) precedes the transport-continuation merge block — validation is deliberately placed before the merge per the block's own comment — so the recomputed value is discarded. — Failure scenario: every transport-continuation response pays for a filter/map/join/trim whose result is discarded, and the orphaned assignment misleads a future maintainer into believing post-merge contentText is consumed downstream (e.g. by the recording), when the recording actually reads the raw parts — exactly the drift the surrounding comments warn against.

Witness: grep — the last contentText occurrence at HEAD is this assignment; the base tree reads it at 4954/4962; deleting the recompute (lines 5204-5208) leaves Tests 339 passed (339) and tsc exit 0.

Suggested fix: delete the recompute; if a future consumer needs a post-merge visible-text value, reintroduce it together with its reader.

中文说明

第 6 轮账本发现,在本 commit 依然存在(已重新验证;第 6 轮的对应线程没有作者回复)。该 transport-continuation 的 contentText 重算在本 PR 自己的记录重写中失去了最后一个消费者:PR 之前它同时供记录门控(...(contentText ? [{ text: contentText }] : []))与 (thoughtContentPart || contentText || hasToolCall || usageMetadata) 析取式使用;这两个读取点都被删除,记录现在直接读取 consolidatedHistoryParts。其余所有 contentText 读取(XML 恢复门控 4969-4972、调试日志 5071/5075、hasAnyContent 5093、lacksVisibleToolResultProgress 5096)都位于 transport-continuation 合并块之前 —— 按该块自己的注释,校验被刻意放在合并之前 —— 因此重算结果被丢弃。— 失败场景:每次 transport-continuation 响应都会为一个结果被丢弃的 filter/map/join/trim 付出开销,且这个孤立赋值会误导未来的维护者,使其以为合并后的 contentText 仍被下游(例如记录)消费,而记录实际读取的是原始 parts —— 正是周围注释所警告的那种漂移。

建议修复:删除该重算;若未来有消费者需要合并后的可见文本值,请连同其读取者一起重新引入。

— qwen3.8-max via Qwen Code /review (v0.21.12)

Comment on lines +5222 to +5224
part.functionCall
? redactStructuredOutputArgsForRecording(part)!
: part,

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] R6-4: Round-6 ledger finding, still standing at this commit (re-verified; open round-6 thread had no reply). This record mapping collapses any part carrying functionCall to {functionCall} only — redactStructuredOutputArgsForRecording returns { functionCall: part.functionCall } for non-structured-output calls without spreading the input — silently dropping every sibling field. Gemini attaches thoughtSignature to the functionCall part, so --resume still loses signatures on that wire — the same class of bug this PR fixes for thought parts (maintainer @wenshao's round-1 review also noted this as a follow-up). — Failure scenario: a Gemini-wire turn records a functionCall part carrying a thoughtSignature; the JSONL record keeps only {functionCall}, so --resume rehydrates the turn without the signature — the signature-loss class this PR exists to fix, surviving on the functionCall path.

Suggested fix: spread the input in the non-redacted return (keeping the sibling fields on the recorded part), or document the sibling-field drop as intentional and open the follow-up issue the maintainer suggested.

中文说明

第 6 轮账本发现,在本 commit 依然存在(已重新验证;第 6 轮的对应线程没有作者回复)。该记录映射会把任何携带 functionCall 的部件折叠为仅 {functionCall} —— redactStructuredOutputArgsForRecording 对非 structured-output 调用返回 { functionCall: part.functionCall } 时不展开输入 —— 静默丢弃所有兄弟字段。Gemini 链路会把 thoughtSignature 附加在 functionCall 部件上,因此 --resume 在该链路上依然丢失签名 —— 与本 PR 为 thought 部件修复的是同一类 bug(维护者 @wenshao 在第 1 轮审查中也已将其列为后续跟进项)。— 失败场景:Gemini 链路的某个轮次记录了一个携带 thoughtSignature 的 functionCall 部件;JSONL 记录只保留 {functionCall},于是 --resume 重建该轮时没有签名 —— 本 PR 要修复的"签名丢失"一类问题在 functionCall 路径上仍然存在。

建议修复:在未经脱敏的返回分支中展开输入(保留记录部件上的兄弟字段),或者明确记录"丢弃兄弟字段是有意为之",并按维护者建议开一个后续 issue。

— qwen3.8-max via Qwen Code /review (v0.21.12)

Comment on lines +1653 to +1658
// The trailing thinking run is relocated to the front as a unit.
expect(blocks.map((b) => b.type)).toEqual([
'thinking',
'text',
'tool_use',
]);

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] R7-6: The new ensureLeadingAssistantThinking suite pins every branch of ensureLeadingThinkingOnToolUseAssistantMessages except the redacted_thinking membership of isThinking (converter.ts:1502, an added line of this diff) — deleting || t === 'redacted_thinking' leaves converter.test.ts 113/113 and anthropicContentGenerator.test.ts 119/119 green. Both sibling passes have dedicated redacted tests using the established private-helper technique ('strips redacted_thinking blocks too' at converter.test.ts:3233; 'treats a redacted_thinking block as already-satisfying' at :3253); the third pass added by this PR gets no equivalent pin. The branch is semantically load-bearing (probe flips), though reachability today is defensive-only (processContent never synthesizes redacted_thinking from Gemini parts). — Failure scenario: a future refactor narrowing isThinking to t === 'thinking' ships green and silently stops relocating a tool_use turn whose first thinking run is redacted ([text, redacted_thinking, tool_use] stays text-leading) — the exact manual-mode rejection (#3786) this option exists to prevent.

Witness: mutation run with || t === 'redacted_thinking' deleted: converter 113 passed, generator 119 passed; probe under real code reorders [text, redacted_thinking, tool_use] to ['redacted_thinking','text','tool_use']; under the mutation the probe fails (expected [ 'text', 'redacted_thinking', … ] to deeply equal [ 'redacted_thinking', 'text', … ]).

Suggested fix: add a sibling test using the siblings' technique — construct [{ role: 'assistant', content: [{ type: 'text', text: 'A' }, { type: 'redacted_thinking', data: 'opaque' }, { type: 'tool_use', id: 't1', name: 'tool', input: {} }] }], run the pass with ensureLeadingAssistantThinking: true, and assert content becomes [{ type: 'redacted_thinking', data: 'opaque' }, { type: 'text', text: 'A' }, { type: 'tool_use', ... }].

中文说明

新的 ensureLeadingAssistantThinking 测试套件钉住了 ensureLeadingThinkingOnToolUseAssistantMessages 的每一个分支,唯独没有钉住 isThinking 中的 redacted_thinking 成员(converter.ts:1502,本 diff 新增的一行)—— 删除 || t === 'redacted_thinking' 后,converter.test.ts 113/113 与 anthropicContentGenerator.test.ts 119/119 依然全绿。两个同族 pass 都有专门的 redacted 测试,使用既有的"访问私有 helper"技术(converter.test.ts:3233 的 'strips redacted_thinking blocks too'、:3253 的 'treats a redacted_thinking block as already-satisfying');本 PR 新增的第三个 pass 却没有对应的钉住测试。该分支在语义上是有效的(探针会翻转),尽管目前只有防御性可达(processContent 不会从 Gemini 部件合成 redacted_thinking)。— 失败场景:未来某次重构把 isThinking 收窄为 t === 'thinking',会在测试全绿的情况下静默地不再重排"第一段 thinking run 是 redacted"的 tool_use 轮次([text, redacted_thinking, tool_use] 保持 text 打头)—— 正是本选项要预防的 manual 模式拒绝(#3786)。

建议修复:按同族测试的技术补一个测试 —— 构造 [{ role: 'assistant', content: [{ type: 'text', text: 'A' }, { type: 'redacted_thinking', data: 'opaque' }, { type: 'tool_use', id: 't1', name: 'tool', input: {} }] }],以 ensureLeadingAssistantThinking: true 运行该 pass,断言 content 变为 [{ type: 'redacted_thinking', data: 'opaque' }, { type: 'text', text: 'A' }, { type: 'tool_use', ... }]

— qwen3.8-max via Qwen Code /review (v0.21.12)

Comment on lines +960 to +965
* Return-value shape. The returned array preserves whatever ordering
* `processStreamResponse` produced: zero or more thought episodes (each its
* own `Part`) freely interleaved with functionCall/text parts in original
* stream order -- not just a single leading thought ahead of everything
* else. {@link GeminiChat.coalesceRecoveryPairs} relies on this by feeding
* the merged result back as `previousParts` on the next recovery iteration;

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] R7-8: The rewritten 'Return-value shape' doc makes two claims the code does not uphold: (a) it says coalesceRecoveryPairs feeds "the merged result back as previousParts on the next recovery iteration", but the loop walks from the tail (appendRecoveryContinuationParts(precedingModel.parts, modelContinuation.parts)), so the merged result is always the SECOND argument (continuationParts) while previousParts is the next-older never-merged turn; (b) "in original stream order" does not hold across a multi-iteration merge — a later continuation's text is folded ACROSS an intermediate continuation's trailing episode. — Failure scenario: probe — turn A [textA] truncates at MAX_TOKENS; continuation B1 is itself truncated with [textB1, episodeB1(signed)]; continuation B2 completes with [textB2, functionCall] → final history [{text:'alpha one bravo two charlie three'}, {episode 'bravo'}, {functionCall}]: charlie three, streamed after the episode, durably precedes it at index 0. The shape stays replay-valid (no 400), so the cost is a silent stream-order inversion in coalesced history plus a doc stating invariants the mechanics do not hold — a future maintainer relying on "original stream order" or on merged results arriving as previousParts will mis-reason about the dedup path.

Witness: PROBE-R78-HISTORY[1] model: [{"text":"alpha one bravo two charlie three"},{"text":"episode bravo","thought":true,"thoughtSignature":"sigB1"},{"functionCall":...}] — TEXT-INDEX: 0, EPISODE-INDEX: 1.

Suggested change
* Return-value shape. The returned array preserves whatever ordering
* `processStreamResponse` produced: zero or more thought episodes (each its
* own `Part`) freely interleaved with functionCall/text parts in original
* stream order -- not just a single leading thought ahead of everything
* else. {@link GeminiChat.coalesceRecoveryPairs} relies on this by feeding
* the merged result back as `previousParts` on the next recovery iteration;
* Return-value shape. The returned array preserves relative order within each
* side: zero or more thought episodes (each its own `Part`) freely interleaved
* with functionCall/text parts -- not just a single leading thought ahead of
* everything else; a later continuation's suffix is folded into the previous
* turn's last plain-text part, which may precede a trailing episode of an
* intermediate continuation. {@link GeminiChat.coalesceRecoveryPairs} relies on
* this by feeding the merged result back as `continuationParts` on the next
* recovery iteration;

If true stream order is the intended invariant instead, append the suffix as a fresh text part after the trailing episode rather than folding it into the anchor.

中文说明

重写后的 'Return-value shape' 文档有两处与代码不符:(a) 文档说 coalesceRecoveryPairs "在下一个恢复迭代中把合并结果作为 previousParts 传回",但该循环是从历史尾部向前遍历(appendRecoveryContinuationParts(precedingModel.parts, modelContinuation.parts)),因此合并结果永远是第二个参数(continuationParts),而 previousParts 是更靠前的、从未被合并过的轮次;(b) "保持原始流顺序"在多次迭代的合并中并不成立 —— 更晚的 continuation 文本会被折叠到越过中间 continuation 尾部 episode 的位置。— 失败场景:探针 —— 轮次 A [textA] 在 MAX_TOKENS 处截断;continuation B1 自身又被截断,内容为 [textB1, episodeB1(带签名)];continuation B2 以 [textB2, functionCall] 完成 → 最终历史为 [{text:'alpha one bravo two charlie three'}, {episode 'bravo'}, {functionCall}]:在 episode 之后才流式输出的 charlie three 被持久化到 index 0、排在 episode 之前。该形状仍然是可回放合法的(不会 400),因此代价是合并历史中的静默流顺序倒置,外加一份声明了机制并不满足的不变量的文档 —— 未来依赖"原始流顺序"或"合并结果以 previousParts 到达"的维护者会对去重路径做出错误推理。

上方 suggestion 块给出了修正后的文档措辞;若真正的意图是保持流顺序,则应把后缀作为新的文本部件追加到尾部 episode 之后,而不是折叠进锚点文本。

— qwen3.8-max via Qwen Code /review (v0.21.12)

@netbrah

netbrah commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Hi @wenshao @qqqys, are we aligned on this?

netbrah pushed a commit to netbrah/qwen-code-upstream-pr that referenced this pull request Aug 20, 2026
Addresses a Critical from PR QwenLM#8260 review (and the related "three
uncoordinated predicates" Suggestion it escalated): contentText's
filter (`part.text && !part.thought`) and the XML-recovery removal
loop's filter (`isValidNonThoughtTextPart`, which additionally rejects
any part carrying `thoughtSignature`) disagreed on what counts as
"visible text." A part with `thoughtSignature` set but no `thought:
true` -- a real wire shape (loggingContentGenerator.ts's stream
aggregation spreads `thought` and `thoughtSignature` independently) --
was picked up by contentText for XML detection but survived the
removal loop untouched: recovery fired, but the raw `<invoke>` XML was
never stripped, leaking it into durable history duplicated alongside
the recovered functionCall.

Introduce a single `isVisibleTextPart` predicate (`Boolean(part.text)
&& !part.thought`) shared by contentText's initial computation, its
post-recovery recompute, and the removal loop's textIndices scan.
Deliberately the looser of the two prior predicates, not the stricter
one: narrowing contentText itself to exclude thoughtSignature-bearing
text would make `hasAnyContent` treat genuine visible text as absent,
throwing "Model stream ended with empty response text" on ordinary
turns. flushThoughtEpisode always sets `thought: true` on episode
parts, so `!part.thought` alone (already contentText's semantics)
already protects reasoning episodes from the removal loop without
isValidNonThoughtTextPart's stricter signature exclusion.

Adds a regression test that reproduces the leak on unfixed code
(confirmed failing before this fix, passing after) with a plain-text
part carrying a stray thoughtSignature and XML content.

Also addresses two outstanding test-coverage Suggestions from the same
review round:
- converter.test.ts: a multi-thinking-run case for
  ensureLeadingAssistantThinking, guarding the "only the first run
  moves" invariant against a hoist-all-thinking mutant that the
  existing single-run test couldn't catch.
- anthropicContentGenerator.test.ts: a generator-level adaptive-mode
  test mirroring the manual-mode one, guarding the `thinking?.type ===
  'enabled'` gate against a `!!thinking` regression that would
  reintroduce the hoist-every-thinking corruption on adaptive models.
- geminiChat.test.ts: asserts the interleaved-episode test's recorded
  JSONL turn (not just in-memory history) preserves both reasoning
  episodes and their signatures, guarding --resume fidelity against a
  recording-only regression that in-memory assertions can't see.
netbrah pushed a commit to netbrah/qwen-code-upstream-pr that referenced this pull request Aug 20, 2026
…t the latest

Addresses the review round on QwenLM#8260.

ensureLeadingThinkingOnLatestAssistantMessage repaired only the most
recent assistant message, which was wrong in two independent ways:

- QwenLM#3786 describes the anthropic-compatible rejection against a PRIOR
  assistant turn carrying tool_use, and injectEmptyThinkingOnToolUseTurns
  correspondingly repairs every tool_use turn. Under latest-only scoping a
  turn normalized while it was current reverts to the text-leading shape on
  the next request, so the failure surfaces one turn after the turn that
  produced it.
- Keying the reorder on "is this the latest assistant message" made a
  turn's serialization depend on its position in history, so the same turn
  went out two different ways on consecutive requests. Since
  addCacheControlToMessages anchors its breakpoint on the last user
  message, that rewrote the cached prefix and forced a full prompt-cache
  re-read every turn.

Renamed to ensureLeadingThinkingOnToolUseAssistantMessages and gated on
tool_use, matching the option's own documented scope.

Also in this round:

- Apply dropDanglingUnsignedTrailingThought inside the XML tool-call
  recovery branch, before the recovered functionCall parts are appended.
  Recovery's gate requires hasToolCall === false, which is exactly when the
  per-stream drop early-returns, so an unsigned trailing episode survived
  and was then paired with a tool_use -- permanently wedging the session
  once the tool result returned.
- Drop the dead `.filter(part => part !== null)` in the recording path;
  redactStructuredOutputArgsForRecording only returns null for parts the
  enclosing ternary already excludes.
- Correct dropDanglingUnsignedTrailingThought's doc, which overclaimed that
  trailing-only scope distinguishes a truncated signing-provider episode
  from a non-signing provider's ordinary trailing thought. It does not; a
  truncated DeepSeek stream has the identical shape. Document the accepted
  false positive and the fact that the coalescing call site never reaches
  the JSONL record.
- Document the mirror-image episode limitation: two adjacent text-less
  signed thought parts concatenate their signatures into one part valid for
  neither block, newly reachable on the OpenAI Responses wire (QwenLM#8169).

Tests: all four new behaviors are mutation-verified (fix reverted -> red,
restored -> green). 556/556 pass across geminiChat.test.ts and
anthropicContentGenerator/.
netbrah pushed a commit to netbrah/qwen-code-upstream-pr that referenced this pull request Aug 20, 2026
…gText is re-inserted

Addresses review round 3 finding R3-1 on QwenLM#8260. The reviewer is right and
this is a hole in the previous round's own fix.

The third dropDanglingUnsignedTrailingThought call site was placed after
`recovery.remainingText` was spliced back into consolidatedHistoryParts.
When the dangling unsigned episode PRECEDES the consumed XML text part, the
re-inserted text lands behind the episode, so the trailing-only check sees a
text part last, no-ops, and the appended functionCall parts persist
`[thought(unsigned), text, functionCall]` -- an active tool-use turn holding
unsigned thinking, which makes
dropUnsignedThinkingFromAssistantMessages throw on every subsequent request.
Same permanent wedge the call site was added to prevent, reached by a
different shape.

The previous round only considered the episode-trailing case and treated a
preceding unsigned episode as the documented non-trailing residual risk.
That was wrong here: the episode IS trailing at the moment the consumed text
parts are spliced out, and only the re-insertion pushes it out of last
position.

Moved the drop into that window -- after the splice-out, before both the
re-insertion and the append -- which is the only point where a dangling
episode is guaranteed to be the last element. `insertAt` is now clamped
against the post-drop length, since the drop can shorten the array.

Regression test uses the trigger shape the reviewer named: an unsigned
episode followed by a plain-text part carrying a stray thoughtSignature and
no thought flag (the wire shape isVisibleTextPart's own doc calls out as
real), with non-empty remainingText so the ordering is observable. Confirmed
failing before this change and passing after.

575/575 across geminiChat.test.ts and anthropicContentGenerator/ on top of
the merged main; tsc, eslint and prettier clean.
netbrah pushed a commit to netbrah/qwen-code-upstream-pr that referenced this pull request Aug 20, 2026
…ivergence verdict

Companion documentation to QwenLM#8533 and follow-up from PR QwenLM#8260's
architectural review round. Two docs:

- 2026-08-04-reasoning-episode-invariants.md: enumerates every place
  Content[]/Part[] history is mutated after a content generator
  produces it, and whether each site preserves the thought/
  thoughtSignature reasoning-replay invariant Anthropic's strict
  tool-use-chain contract depends on.
- 2026-08-04-resume-jsonl-reasoning-divergence.md: confirms --resume
  can reconstruct the exact dangling-unsigned-thought hazard PR QwenLM#8260
  fixed for the live path, because the in-memory fix in
  coalesceRecoveryPairs never reaches the on-disk JSONL transcript.

Produced by four independent reviewers on different model families,
cross-checked against each other and against the actual code.
@wenshao

wenshao commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — built a real local harness and ran this end-to-end

@netbrah @qqqys — I verified this against a real qwen CLI process talking to a mock Anthropic Messages endpoint (SSE), rather than at unit level, so the thing being asserted is the actual bytes the CLI puts back on the wire on the next turn. Verified at head 6b3e68adc5, A/B against the merge-base ac78acd3c5.

Verdict: the fix does what it says, and I could not find a regression. Recommending merge once it is brought up to date with main (it is 311 commits behind — see Merge readiness below, where I already validated the merged tree).

Harness — how the numbers below were produced

Two worktrees (ac78acd3c5 and 6b3e68adc5), each with its own npm ci + npm run bundle, run against a Node SSE server that speaks the Anthropic Messages protocol and logs every inbound request body:

ANTHROPIC_API_KEY=… ANTHROPIC_BASE_URL=http://127.0.0.1:PORT \
  node <tree>/dist/cli.js --approval-mode yolo --model <model> -p "Read alpha.txt and beta.txt"

HOME is isolated per run so no real ~/.qwen settings leak in. The server scripts turn 1 as a thinking/tool-call turn and turn 2 as a plain answer; the CLI really executes the read_file calls, and request #2 is the artifact under test — that is history after consolidation, converted back to the Anthropic wire format.


1. The primary bug reproduces, and the fix holds

interleaved + back-to-back

One correction to how #8258 frames the impact. The issue says this "degrades gracefully (content dropped, not corrupted)". On the wire it is worse than that. main does not just drop SIG-EPISODE-TWO-BBBB — it emits a single thinking block whose thinking field is the concatenation of both episodes' text while still carrying episode one's signature. An Anthropic thinking signature is computed over that block's own text, so what main sends is a mis-signed block, not a merely lossy one. That moves the failure mode from "model loses replay context" toward "server-side signature verification has something to reject", which I think strengthens the case for merging rather than deferring.

2. Secondary behaviours, including the regression I most wanted to disprove

split signature, ordering, manual mode

Scenario 5 is the one I went looking for a regression in. Removing the "hoist thinking to parts[0]" behaviour could plausibly break manual-mode extended thinking's leading-thinking requirement. It does not: with thinking:{type:'enabled'}, the new ensureLeadingThinkingOnToolUseAssistantMessages reproduces main's output byte-for-byte, including for the awkward [text, tool_use, thinking, tool_use] shape raised in the still-open thread on converter.test.ts:1177. That mechanism is real — the run is relocated even when it begins after a tool_use — but it is not a change in behaviour, and it is confined to manual mode. Adaptive mode is left in true chronological order (Scenario 4).

I also confirmed on the wire that interleaved-thinking-2025-05-14 is sent for both {type:'adaptive'} and {type:'enabled'}, so the premise behind the mergeConsecutiveAssistantMessages change is factually correct.

3. --resume fidelity

The session JSONL matches in-memory history exactly:

// PR — .qwen/projects/…/chats/<id>.jsonl
{"role":"model","parts":[
  {"text":"EPISODE-ONE: …","thought":true,"thoughtSignature":"SIG-EPISODE-ONE-AAAA"},
  {"functionCall":{"id":"toolu_alpha","name":"read_file",}},
  {"text":"EPISODE-TWO: …","thought":true,"thoughtSignature":"SIG-EPISODE-TWO-BBBB"},
  {"functionCall":{"id":"toolu_beta","name":"read_file",}}]}

// main — second signature gone, both texts under the first signature
{"role":"model","parts":[
  {"text":"EPISODE-ONE: …EPISODE-TWO: …","thought":true,"thoughtSignature":"SIG-EPISODE-ONE-AAAA"},
  {"functionCall":{"id":"toolu_alpha",}},{"functionCall":{"id":"toolu_beta",}}]}

Prompt-cache prefix stability also checks out: assistant turn 1 serializes identically across consecutive requests in a 3-turn manual-mode session. The only byte that moves between request #2 and #3 is the cache_control breakpoint relocating to the new last user message — identical on main.

4. Tests, mutation probes, merge readiness

tests and mutation probes

I ran three mutation probes rather than trusting a green suite, because a passing E2E proves nothing if the harness cannot see the behaviour. Each mutation was applied to the PR source, rebundled, and re-run through the same real-CLI path; all three produce visibly wrong wire output, so the new code is load-bearing and the harness is non-vacuous.

Probe 1 is worth calling out: reverting the XML-recovery removal predicate to the pre-PR .text !== undefined makes the reasoning episode vanish entirely — text and signature — from the turn-2 request. That confirms the #8003 interaction described in the PR body is real. Note it is a hazard this PR's own restructuring creates (pre-PR, episodes never lived in consolidatedHistoryParts, so the bare check could not reach them) and then closes in the same diff. A/B against main shows no difference there, which is the correct outcome.

Merge readiness. The branch is 311 commits behind main, and main has touched geminiChat.ts six times since the merge-base. I merged main (ea872a4621) into the branch locally: no conflicts, 585/585 tests green on the merged tree, and Scenario 1 still passes E2E there. So the staleness is real but benign.


Residual items — none blocking, listed for the record

  1. dropDanglingUnsignedTrailingThought's accepted false positive is real. A non-signing provider (DeepSeek) truncated mid-reasoning after a tool call loses its trailing reasoning from both history and the JSONL. The PR documents this and argues losing a fragment beats wedging a session, which I agree with. I did not reproduce it live — it needs a DeepSeek-shaped base URL — so this is code-reading only.
  2. Media parts now persist to the session JSONL. Declared in the PR body, and correct for --resume fidelity, but it does mean model-produced base64 inlineData lands on disk where it previously never did. Worth watching for image-producing models. Not exercised by my harness.
  3. Small doc-accuracy nit. The PR body says recordAssistantTurn "now records every consolidated part verbatim". Not quite: redactStructuredOutputArgsForRecording returns { functionCall } without spreading, so siblings on a functionCall part are dropped. In practice unreachable here — loggingContentGenerator.ts:1015 already builds { functionCall: part.functionCall } upstream — so this is wording, not behaviour.
  4. Stale test count. The body's "369 across both files" is now 571 across the three touched test files (585 on the merged tree). Worth refreshing before merge.
  5. 26 review threads are still open, but I read through them and none is a standing Critical at 6b3e68adc5: the two round-4 Criticals and the round-6 transport-continuation Critical are answered by the third and fourth dropDanglingUnsignedTrailingThought call sites, and round 7 raised Suggestions only.

Not covered by this verification

The real Anthropic API (mock only), the OpenAI Responses wire (#8169), and the DeepSeek provider path. Documented limitation 2 — two text-less signed thought parts concatenating into a signature valid for neither block — is unreachable on the Anthropic wire and so was not exercised here; it stays a genuine hazard for #8169 and is correctly flagged there.

中文版本

维护者验证 —— 搭了一套真实的本地环境做端到端验证

@netbrah @qqqys 我没有停留在单测层面,而是让真实的 qwen CLI 进程去访问一个模拟的 Anthropic Messages 端点(SSE),这样被断言的对象就是 CLI 在下一轮真正发到线上的字节。验证基于 head 6b3e68adc5,并与 merge-base ac78acd3c5 做 A/B 对照。

结论:这个修复确实做到了它声称的事情,我没有找到回归。建议合并,前提是先跟 main 同步(目前落后 311 个提交 —— 见下文"可合并性",我已经验证过合并后的结果)。

验证环境

两个 worktree(ac78acd3c56b3e68adc5),各自独立 npm ci + npm run bundle,对接一个用 Node 写的、说 Anthropic Messages 协议的 SSE 服务器,它会记录每一个进来的请求体:

ANTHROPIC_API_KEY=… ANTHROPIC_BASE_URL=http://127.0.0.1:PORT \
  node <tree>/dist/cli.js --approval-mode yolo --model <model> -p "Read alpha.txt and beta.txt"

每次运行都隔离 HOME,避免真实的 ~/.qwen 配置串入。服务器把第 1 轮编排成"思考 + 工具调用"的轮次,第 2 轮返回纯文本答复;CLI 会真正执行 read_file 调用,而第 2 个请求就是被测对象 —— 也就是整合之后的历史,再转换回 Anthropic 线格式的样子。

1. 主问题可复现,修复成立

见上方第一张截图。

#8258 描述的一处修正。 Issue 里说这是"优雅降级(内容丢失,而非损坏)"。在线格式上,情况比这更糟。main 不只是丢掉了 SIG-EPISODE-TWO-BBBB —— 它发出的是一个 thinking 区块,其 thinking 字段是两个片段文本的拼接,却仍然带着第一个片段的签名。Anthropic 的 thinking 签名是针对该区块自身文本计算的,所以 main 发出去的是一个签名与内容不匹配的区块,而不仅仅是有损的区块。这把失效模式从"模型丢失了可重放的推理上下文"推向了"服务端签名校验有理由拒绝该请求",我认为这反而更支持尽快合并,而不是继续搁置。

2. 次要行为,以及我最想证伪的那个回归

见上方第二张截图。

场景 5 是我专门去找回归的地方。去掉"把 thinking 提到 parts[0]"这个行为,理论上可能破坏手动模式扩展思考的"必须以 thinking 开头"的约束。结果并没有:在 thinking:{type:'enabled'} 下,新增的 ensureLeadingThinkingOnToolUseAssistantMessagesmain 的输出逐字节一致,包括 converter.test.ts:1177 那条尚未解决的评论所指出的 [text, tool_use, thinking, tool_use] 这种别扭形态。那条评论指出的机制是真实存在的 —— 即使 thinking 段起始于某个 tool_use 之后,它确实会被搬到最前面 —— 但这并不是行为变化,而且只发生在手动模式内。自适应模式保持了真正的时间顺序(场景 4)。

我还在线格式上确认了:interleaved-thinking-2025-05-14{type:'adaptive'}{type:'enabled'} 两种情况下都会发送,所以 mergeConsecutiveAssistantMessages 那处改动所依据的前提是成立的。

3. --resume 保真度

会话 JSONL 与内存中的历史完全一致:PR 分支上两个片段各自带着自己的签名落盘;main 上第二个签名消失,两段文本被并到第一个签名之下。

prompt cache 前缀的稳定性也没问题:在一个 3 轮的手动模式会话里,第 1 个 assistant 轮次在相邻两次请求中的序列化结果完全相同。请求 #2#3 之间唯一移动的字节是 cache_control 断点挪到了新的最后一条 user 消息上 —— 这一点在 main 上表现一致。

4. 测试、变异探针与可合并性

见上方第三张截图。

我没有满足于"测试全绿",而是跑了三个变异探针 —— 因为如果验证环境根本看不见这个行为,那么 E2E 通过什么也说明不了。每个变异都作用在 PR 源码上,重新打包,再走同一条真实 CLI 路径;三个变异都产生了肉眼可见的错误线格式输出,说明新增代码确实在承担作用,验证环境也不是空转的。

探针 1 值得单独说明:把 XML 恢复路径的判断谓词退回到修复前的 .text !== undefined,会让推理片段从第 2 个请求中彻底消失 —— 文本和签名一起没了。这印证了 PR 描述中提到的 #8003 交互问题是真实的。需要说明的是,这个隐患其实是本 PR 自身的结构调整所引入的(修复前推理片段根本不在 consolidatedHistoryParts 里,那个宽松判断够不着它们),并在同一个 diff 里被关掉。与 main 做 A/B 时这里没有差异,这正是应有的结果。

可合并性。 分支落后 main 311 个提交,而 main 自 merge-base 以来已经改过 geminiChat.ts 六次。我在本地把 mainea872a4621)合入了该分支:没有冲突,合并后的树上 585/585 测试全绿,场景 1 的 E2E 在合并后依然通过。所以落后是事实,但是良性的。

遗留事项 —— 都不阻塞合并,仅作记录

  1. dropDanglingUnsignedTrailingThought 所接受的误判是真实存在的。 一个不做签名的供应商(DeepSeek)如果在工具调用之后、推理中途被截断,其尾部推理会同时从历史和 JSONL 中丢失。PR 已经记录了这一点,并论证"丢一个片段好过把会话彻底卡死",我认同这个取舍。我没有实地复现它 —— 那需要一个 DeepSeek 形态的 base URL —— 所以这一条仅基于代码阅读。
  2. 媒体部件现在会写入会话 JSONL。 PR 描述中已声明,对 --resume 保真度而言也是正确的取舍,但这确实意味着模型产出的 base64 inlineData 会落到磁盘上,而此前从不会。对会产图的模型需要留意。我的验证环境没有覆盖这一条。
  3. 一处措辞上的小问题。 PR 描述说 recordAssistantTurn "现在会逐字记录所有整合后的部件"。并不完全准确:redactStructuredOutputArgsForRecording 返回的是 { functionCall },没有展开原部件,因此 functionCall 部件上的同级字段会被丢掉。不过在当前代码里实际不可达 —— loggingContentGenerator.ts:1015 在上游就已经构造了 { functionCall: part.functionCall } —— 所以这是措辞问题,不是行为问题。
  4. 测试数字已过期。 描述里的"两个文件合计 369 个"现在是三个被改测试文件合计 571 个(合并后的树上是 585)。建议合并前更新一下。
  5. 仍有 26 条评论线程未解决,但我逐条读过,在 6b3e68adc5没有仍然成立的 Critical:第 4 轮的两个 Critical 和第 6 轮的 transport-continuation Critical,都已由第三、第四个 dropDanglingUnsignedTrailingThought 调用点回应;第 7 轮只提出了 Suggestion。

本次验证未覆盖的部分

真实的 Anthropic API(本次仅用 mock)、OpenAI Responses 链路(#8169),以及 DeepSeek 供应商路径。已记录的限制 2 —— 两个无文本的已签名 thought 部件拼接出一个对两个区块都无效的签名 —— 在 Anthropic 链路上不可达,因此本次没有触发;它对 #8169 仍是真实隐患,PR 中已正确地标了出来。

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

wenshao commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

Scripted assertions: 45 passed · 0 failed · 45 total

Flakiness gate: ✅ 3 changed test file(s) x 5 identical rounds, no divergence

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

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

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

抖动门:✅ 3 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #8260 Deep Verification — fix(core): preserve every reasoning episode's signature during history consolidation

Verdict: merge-ready — 45/45 scripted assertions passed (pass: 45, fail: 0). Verified head: 6b3e68adc53b4b7a4b367e12cef76dae7cbe3a87, verified against merge-base tip 7385b278b2017a0b6bfeff59380d23b57848fd4a (HEAD^1 of the merge-ref checkout).

中文摘要
  • 结论merge-ready。45/45 脚本化断言通过,0 失败;未发现阻塞性问题。
  • A/B 结论(见「Central claim — A/B cell table」):用同一个多片段推理流驱动真实 GeminiChat,base 侧产生合并后的单一 thought 块(只保留第一个签名、位置被提前、截断场景下把 sig1 错挂在合并文本上、JSONL 丢失媒体部件);head 侧每个推理片段保留各自的签名与原始位置,截断产生的未签名尾片段被丢弃,XML 恢复与媒体记录均按声明工作。两臂各自 11/11 通过(base 臂断言的就是预期的"坏"形状)。
  • 转换器(见「Converter cell table」):手动思考模式下 text 开头的 tool_use 轮次被修复为 thinking 开头;自适应模式保持时间序;相邻 assistant 合并由"全部 thinking 提前"改为按序拼接;stripTrailingAssistantPrefill 重排序保住了被提升为最新轮次的已签名空文本 thinking 块;序列化与位置无关(prompt-cache 前缀稳定)。
  • 变异矩阵:6/6 变异体被 PR 自带测试击杀,零存活;中心新测试经"撤掉核心 hunk"验证非空泛(失败信息为行为断言而非崩溃)。
  • Findings:无阻塞项。仅一处更正(测试计划中的 369 个测试数字已过时,现为 465,命令本身照跑全绿)。
  • 未覆盖:逐 commit 归因(depth-2 浅克隆)、全仓测试/类型检查(由 PR 自身 CI 覆盖)、对真实 Anthropic/OpenAI 端点的联线验证、feat(core): add OpenAI Responses API content generator #8169 的 OpenAI Responses 转换器(不在此 base 中)。详见 "Not covered"。

Central claim — A/B cell table

Central claim: geminiChat.ts turn consolidation preserves every reasoning episode as its own Part, each with its own thoughtSignature, in its original position relative to tool calls — instead of merging all thought parts into one hoisted blob and keeping only the first signature.

Harness: harness/ab-harness.mjs drives the real GeminiChat.sendMessageStream (tsx over the TS source, zero module mocks — only injected collaborators: a fake ContentGenerator, a fake ChatRecordingService, a plain config object). Identical scripted streams on both arms; the base arm asserts the broken shapes are produced (an expected base failure is encoded as a passing assertion). Witnesses: 01-ab-head-fixed-shapes.png (head) and 02-ab-base-broken-shapes.png (base); raw logs harness/head-run.log, harness/base-run.log.

# scenario (stream shape) BASE cell (broken, asserted) HEAD cell (fixed, asserted)
S1 two episodes interleaved with two tool calls [{text:"AB",thought,sigA}, call1, call2] — episodes merged, hoisted, sigB lost (in-memory and JSONL) [{A,thought,sigA}, call1, {B,thought,sigB}, call2] — both signatures, original positions
S2 one episode, signature split across two chunks thoughtSignature:"sigFrag1"truncated thoughtSignature:"sigFrag1sigFrag2" — concatenated
S3 back-to-back episodes, no intervening tool call [{text:"AB",thought,sigA}, {final}] [{A,sigA}, {B,sigB}, {final}]
S4 stream truncated (MAX_TOKENS) mid-episode-2 after a tool call [{text:"ep1ep2 partial",thought,sig1}, call1]sig1 (valid for ep1 only) silently attached to the merged ep1+ep2 text [{ep1,sig1}, call1] — dangling unsigned trailing episode dropped
S5 reasoning episode + XML tool-call recovery on the same turn episode survives (lives in thoughtContentPart, outside the removal loop) episode survives in place: [{planning,sig-survive}, fc(read_file)]isVisibleTextPart keeps it out of the removal loop
S6 plain-text part with stray thoughtSignature + XML (the leak shape) raw XML consumed, stray-signature part retained byte-identical to base — parity cell; the leak existed mid-PR-history and is closed in the final state
S7 declared change: media part in a model turn history carries inlineData, but recorded JSONL drops it ([{text}] only) recorded JSONL carries inlineData verbatim — the declared --resume fidelity change, verified deliberate

Counts: head 11/11, base 11/11 — every flip cell proves the change load-bearing; S5/S6/S7 show the two related fixes and the declared recording change behave as described.

Converter cell table (secondary claim)

Harness: harness/converter-harness.mjs drives the real AnthropicContentConverter.convertGeminiRequestToAnthropic with Gemini histories and asserts the exact emitted Anthropic block arrays. Witnesses: 03-converter-head.png, 04-converter-base.png; logs harness/converter-{head,base}.log.

# scenario BASE HEAD
C1 manual mode (ensureLeadingAssistantThinking), turn converts to [text, thinking, tool_use] ships the invalid text-leading shape as-is repaired to [thinking, text, tool_use]
C2 adaptive mode (option off), same turn chronological pass-through chronological pass-through (parity — adaptive untouched)
C3a adjacent assistant messages merged, adaptive hoist: [thinkingX, thinkingY, textA, tool_use] concat: [textA, thinkingX, thinkingY, tool_use]
C3b same merge, manual mode hoisted concat then first thinking run moved to front — required shape, same result here
C4 same turn serialized with/without a later turn following (head-only invariant) n/a (mechanism absent) byte-identical serialization — position-independent, prompt-cache prefix stable
C5 empty trailing assistant (prefill artifact) popped; earlier tool turn carries signed empty-text thinking pop happens AFTER dropEmptyTextThinkingBlocks → promoted latest turn loses its signed thinking('') block[tool_use] only (invalid in manual mode) pop first → signed empty-text thinking kept: [thinking('',sigEmpty), tool_use]
C6 turn with two thinking runs: [text, thinkF, tu1, thinkS, tu2], manual mode pass-through only the FIRST run moves: [thinkF, text, tu1, thinkS, tu2] — later runs untouched

Counts: head 8/8, base 7/7 (C4 skipped on base — the mechanism does not exist there).

Mutation matrix (vacuity of the PR's new tests)

Driver: harness/matrix-driver.mjs applies each mutant to a scratch worktree at HEAD, runs the target suite, asserts the expected test titles go red, restores the file. Witness: 05-mutation-matrix-6-of-6-killed.png; log harness/matrix-run.log.

mutant guard removed/reverted suite killed red tests (expected ⊆ observed)
M1 episode-split condition deleted geminiChat.test.ts ✅ 1/352 back-to-back split test
M2 dropDanglingUnsignedTrailingThought disabled geminiChat.test.ts ✅ 5/352 all four call-site tests + the accepted-false-positive pin
M3 removal-loop predicate → bare .text !== undefined geminiChat.test.ts ✅ 4/352 episode-preservation test + 3 interaction tests
M6 removal-loop predicate → stricter isValidNonThoughtTextPart geminiChat.test.ts ✅ 2/352 the XML-leak regression test + the non-trailing-episode test
M4 leading-thinking scoped to latest message only converter.test.ts ✅ 2/113 every-tool_use-turn test + position-independence test
M5 merge restored to hoist-all-thinking converter.test.ts ✅ 3/113 chronological-merge tests + multi-run test

Zero survivors. Notes:

  • Central-test vacuity check: M1 is the revert of the central hunk's boundary logic; the new back-to-back test fails it with a behavioral assertion — AssertionError: expected [ { text: 'AB', …(2) }, …(1) ] to deeply equal [ …(3) ] (mutant merges episodes A+B; test expects two distinct episodes). Not a crash, not an import break.
  • Same-file positive control: every mutant lands in the very file its killing suite imports (geminiChat.ts ↔ geminiChat.test.ts; converter.ts ↔ converter.test.ts); M1's single kill proves the chosen vitest command collects tests that execute the mutated file.
  • Layered guards adjudicated, not conflated: M1 kills only the back-to-back shape because the interleaved shape is protected by a different clause (the flush on non-thought parts). The two guards cover disjoint shapes, so neither row is a false survivor of the other; reverting them together was therefore unnecessary.
  • Reverse mutation: the documented residual limitations (two unsigned back-to-back episodes merge; two text-less signed parts concatenate signatures) are pinned by tests asserting current behavior (both green in the gate run); a scratch "fix" for either would turn those pins red by design, so no candidate-further-fix run was made.

Targeted gates

harness/gate-driver.mjs — witness 06-gates-green-586-tests.png, log harness/gate-run.log:

gate result
geminiChat.test.ts + converter.test.ts 465 passed, 0 failed
anthropicContentGenerator.test.ts 121 passed, 0 failed

Suite liveness (the gate can go red) is proven by the mutation matrix itself — six distinct mutants turned it red.

Corrections

  • The PR's Reviewer Test Plan says the two suites contain 369 tests; they now contain 465 (+96 added by later review rounds). The command in the plan was run verbatim and passes; only the count is stale. This is a correction to the description, not a request to change code.
  • The metadata snapshot's baseRefOid (ac78acd…) has drifted and is not present locally; verification used the merge-ref base tip HEAD^1 = 7385b27 per the CI checkout contract.

Findings

No blocking findings. Two observations, both confirmations rather than defects:

  1. Declared recording change verified in both directions (S7): base's recordAssistantTurn silently drops inlineData/fileData from the JSONL record; head records them verbatim. The PR's Risk & Scope section declares exactly this (model-produced base64 media now persists on disk for --resume fidelity) — the measurement matches the declaration.
  2. The DeepSeek false positive is real, declared, and pinned: disabling the dangling-drop (M2) turns the documents the accepted false positive… test red along with the four protective call sites — the trade-off (losing a trailing reasoning fragment on a non-signing provider vs. permanently wedging a signing one) is encoded in tests, not just prose.

No injection-style instructions were present in the PR text.

Not covered

  • Per-commit attribution: the checkout is depth-2; only the aggregate HEAD^1..HEAD diff was verified. The snapshot lists 15 commits but git rev-list HEAD^1..HEAD^2 yields 1 locally (shallow boundary), so no per-commit table is presented.
  • Repo-wide suite, lint, typecheck: not re-run — the PR's own CI covers them; only the three affected test files were executed here.
  • Live-wire validation: no Anthropic/OpenAI credentials exist in this sandbox, so the converter oracle is the emitted message/block shape, not API acceptance. The manual-mode "text-leading is rejected" contract is verified against the converter's output shape only.
  • xml-tool-call-fallback.ts internals: unchanged by this PR; exercised only through the changed call sites.
  • OpenAI Responses wire (feat(core): add OpenAI Responses API content generator #8169): not present at this base. The Responses-shaped episode claim is covered only by the suite's Gemini-Part-shaped fixture (test at line 3677), not the actual feat(core): add OpenAI Responses API content generator #8169 converter.
  • Performance/ladder probes: not applicable — the change is a linear single-pass walk with string concatenation and adds no regex/scanner over untrusted text.
  • First verification round: no previous-report.md present; nothing carried forward.

Methodology

CI verify container (node:22-bookworm, node v22.23.2), merge-ref checkout at depth 2; npm ci + npm run build completed before this round. The A/B and converter harnesses import the real production TS modules directly with tsx — no module mocking anywhere in the unit-under-test path; collaborators enter only through constructor/config seams. The base control ran in a scratch worktree at HEAD^1 resolving external deps through the root node_modules (PR touches no package.json/lockfile); packages/core/node_modules was symlinked in (external packages only) and the code-under-test closure was grep-verified to contain zero @qwen-code/* imports, so the head tree's workspace symlinks could not contaminate the control. Mutations ran in a second scratch worktree with the head tree's built dist/ copied in to satisfy vitest's build-prerequisite guard (the three test files import nothing through the package entry, verified by grep; M1's kill additionally proves mutants take effect through src/). Both worktrees were removed after the A/B cells and matrix were captured. Raw per-arm logs and all harness scripts live in tmp/pr8260-verify-20260823-191426/harness/; evidence images in evidence/.

Flakiness gate log

rounds=5 files=3 skipped=0
file packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts
file packages/core/src/core/anthropicContentGenerator/converter.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/anthropicContentGenerator/converter.test.ts
file packages/core/src/core/geminiChat.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/geminiChat.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: PPPPP
  packages/core/src/core/anthropicContentGenerator/converter.test.ts: PPPPP
  packages/core/src/core/geminiChat.test.ts: PPPPP

verdict: pass
summary: 3 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 1 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 1 · packages/core/src/core/geminiChat.test.ts: P (exit 0)
round 2 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 2 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 2 · packages/core/src/core/geminiChat.test.ts: P (exit 0)
round 3 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 3 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 3 · packages/core/src/core/geminiChat.test.ts: P (exit 0)
round 4 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 4 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 4 · packages/core/src/core/geminiChat.test.ts: P (exit 0)
round 5 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 5 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 5 · packages/core/src/core/geminiChat.test.ts: P (exit 0)

Evidence images

01-ab-head-fixed-shapes

02-ab-base-broken-shapes

03-converter-head

04-converter-base

05-mutation-matrix-6-of-6-killed

06-gates-green-586-tests

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao — the review is clean at 6b3e68adc5 (findings in the stage comments above), but the PR now changes 589 production lines in core, which crosses the two-tier rule's 500-line mark where the gate must hand the approval decision to a maintainer instead of casting the bot's vote. Your end-to-end verification and approval already stand on this commit; the remaining call is whether the second approval should be the bot's, a second human's, or an admin merge. The stale round-6 CHANGES_REQUESTED from this bot account (commit 2fe2ee32) still pins reviewDecision and can be dismissed once you pick a path, and the /verify report will land in the lifecycle comment. Needs a human call on this one.

@wenshao
wenshao disabled auto-merge August 23, 2026 19:10
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot's only review on 6b3e68adc53b4b7a4b367e12cef76dae7cbe3a87 is a COMMENTED one, which carries no vote — so it has no verdict of its own on this commit, and main needs two approving reviews: an approval left by another account is a separate vote and does not count as the bot's own. Two different things look like this, and the stage-3 comment above says which: the triage skill deferring on purpose at 3/5 — a fork refactor hitting the approval guardrail, or a core change escalated for maintainer awareness, both normal outcomes — or an earlier approval that a push dismissed, leaving only the comment behind, which needs a fresh review.

⚠️ 机器人在 6b3e68adc53b4b7a4b367e12cef76dae7cbe3a87 上唯一的评审是 COMMENTED不带票 —— 因此它在该 commit 上没有自己的裁决,而 main 需要两个批准(其他账号的批准是另一张票)。有两种情况长这样,上方的 stage-3 评论会说明是哪一种:triage skill 在 3/5 时有意 defer(fork refactor 命中审批护栏,或核心改动被升级交由维护者把关,两者都是正常结果);或者更早的批准被一次推送作废、只剩下这条评论,此时需要重新评审。

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

上方各阶段评论已更新为最新结果。查看工作流运行

Palanisamy, Dinesh and others added 2 commits August 25, 2026 22:52
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI failing: fallback-comment, review-pr, ack-review-request, resolve-pr, delay-automatic-review, authorize, Post Coverage Comment, Integration Tests (CLI, No Sandbox), Desktop Shell (${{ matrix.os }}), Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x), precheck-pr / precheck. Partially reviewed — gaps disclosed.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 5)": running the three tests under vitest to confirm-by-execution (npm install/build prerequisite not attempted at the ceiling); substituted a mechanical source trac….

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Deferred under the convergence posture (round 8, not a blocker) — recorded, not requested in this round:

  • packages/core/src/core/geminiChat.test.ts:2924 — [review] D8-1: new test duplicates the chatWithRecorder helper inline
  • packages/core/src/core/geminiChat.test.ts:11078 — [probe] R7-3: fourth drop call site's signed-keep direction is pinned by no test
  • packages/core/src/core/geminiChat.ts:5832 — [review] R6-3: transport-continuation contentText recompute is a dead store
  • packages/core/src/core/geminiChat.ts:5614 — [review] D8-2: stale "Third call site" ordinal contradicts the doc-block numbering
  • packages/core/src/core/geminiChat.test.ts:4882 — [review] D8-3: "Unreachable on the Anthropic wire" contradicts the converter's signed-empty-text handling
  • packages/core/src/core/geminiChat.ts:5862 — [review] R6-4: record mapping drops sibling fields of functionCall parts (unreachable today)
  • packages/core/src/core/geminiChat.ts:998 — [review] R7-8: 'Return-value shape' doc still claims previousParts feedback and stream order the code does not uphold
中文说明

⚠️ 已从批准降级为评论:CI failing: fallback-comment, review-pr, ack-review-request, resolve-pr, delay-automatic-review, authorize, Post Coverage Comment, Integration Tests (CLI, No Sandbox), Desktop Shell (${{ matrix.os }}), Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x), precheck-pr / precheck。 仅完成部分审查,审查缺口已披露。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 5)"running the three tests under vitest to confirm-by-execution (npm install/build prerequisite not attempted at the ceiling); substituted a mechanical source trac…

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

收敛姿态下延后(第 8 轮,非阻断)——已记录,本轮不要求修改:共 7 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

@netbrah

netbrah commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage

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.

geminiChat.ts history consolidation keeps only the first thoughtSignature per turn, dropping later reasoning episodes

5 participants