Skip to content

fix(core): keep Responses reasoning replay data off foreign wires - #11567

Merged
wenshao merged 6 commits into
mainfrom
fix/issue-9453-cross-wire-signature
Sep 11, 2026
Merged

fix(core): keep Responses reasoning replay data off foreign wires#11567
wenshao merged 6 commits into
mainfrom
fix/issue-9453-cross-wire-signature

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Part.thoughtSignature is the shared cross-provider store for reasoning replay data, but it carries no wire, model, or origin marker, so each content generator interprets the same opaque string its own way. This PR adds one recognizer for the OpenAI Responses replay payload shape — isResponsesReasoningSignature in packages/core/src/utils/thoughtUtils.ts — and applies it at the two request-build points that would otherwise forward a foreign payload. The Anthropic converter now leaves the thinking block unsigned instead of emitting the payload as a native thinking.signature, and the Gemini wire deletes it from the shallow copy stripPartFields already builds. Both keep the visible reasoning text, and the Gemini strip is wire-only, so the caller's history still holds the payload for a later switch back to the Responses API.

Why it's needed

Since #8169 merged, responses-converter.ts writes JSON.stringify({ id, encrypted_content }) into thoughtSignature for every Responses reasoning item. After a provider switch that payload is eligible for forwarding verbatim on the Anthropic wire — the only guard there is typeof part.thoughtSignature === 'string' — and it travels untouched on the Gemini wire, so one provider's opaque replay data goes out as another provider's native signature. This was reproduced on main @ 35a702c33 with synthetic parts and no credentials; see the repro comment on #9453.

This completes a pattern already established in the codebase rather than adding a new heuristic. responses-converter.ts applies the symmetric fallback in the other direction for an unreplayable signature — drop the payload, preserve the human-readable summary, log the drop — and llm-chat.ts (isCompleteResponsesReasoningSignature) plus responses-converter.ts (decodeReasoningSignature) already carry private copies of the same shape check. The new helper is the shared, exported version of that check, placed in the leaf thoughtUtils.ts module both call sites already import from.

Reviewer Test Plan

How to verify

Both new leak tests were confirmed failing before the guard and passing after it, by reverting only the three source files (git checkout HEAD -- packages/core/src/utils/thoughtUtils.ts packages/core/src/core/anthropicContentGenerator/converter.ts packages/core/src/core/llm-content-generator/llm-content-generator.ts) while keeping the two test files, then restoring from a patch.

Before the guard (fix reverted, tests kept):

× AnthropicContentConverter > cross-provider reasoning replay metadata > does not forward a Responses replay payload as a native signature
  → expected '{"id":"rs_68c6c0c9ff5c8191a29b2e78c1a…' to be undefined
× LlmContentGenerator > cross-provider reasoning replay metadata > strips a Responses replay payload but keeps the visible reasoning text
  → expected '{"id":"rs_68c6c0c9ff5c8191a29b2e78c1a…' to be undefined

+ Received:
"{\"id\":\"rs_68c6c0c9ff5c8191a29b2e78c1a40c83\",\"encrypted_content\":\"gAAAAABvcmVhc29uaW5nLXJlcGxheS1wYXlsb2Fk\"}"

 Test Files  2 failed (2)
      Tests  2 failed | 141 passed (143)

After the guard:

npx vitest run src/core/anthropicContentGenerator/converter.test.ts \
  src/core/llm-content-generator/llm-content-generator.test.ts \
  src/utils/thoughtUtils.test.ts

 ✓ src/utils/thoughtUtils.test.ts (15 tests)
 ✓ src/core/llm-content-generator/llm-content-generator.test.ts (27 tests)
 ✓ src/core/anthropicContentGenerator/converter.test.ts (116 tests)
 Test Files  3 passed (3)
      Tests  158 passed (158)

Adjacent wire regression (the other three generators that touch thoughtSignature):

npx vitest run src/core/openaiResponsesContentGenerator/responses-converter.test.ts \
  src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts \
  src/core/openaiContentGenerator/converter.test.ts

 ✓ src/core/openaiResponsesContentGenerator/responses-converter.test.ts (90 tests)
 ✓ src/core/openaiContentGenerator/converter.test.ts (234 tests)
 ✓ src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts (175 tests)
 Test Files  3 passed (3)
      Tests  499 passed (499)

Gates: npm run typecheck in packages/core (tsc --noEmit) passes with zero errors; prettier --check and eslint pass on all five changed files.

Each wire gets both sides asserted, not just "does not throw": a legitimate native signature is forwarded unchanged (Anthropic emits it as thinking.signature, Gemini passes it through), and the Responses payload is absent while thought: true and the visible reasoning text survive. A third test asserts the Gemini strip does not mutate the caller-owned history part, holding it by object identity.

Wire coverage is complete with these two guards: the OpenAI Chat converter never reads thoughtSignature (signature dropped, visible text kept — no leak), and responses-converter.ts already handles its own direction.

Evidence (Before & After)

N/A — not a user-visible/TUI change. The wire-level before/after evidence is the failing-then-passing assertions above.

Tested on

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

✅ tested · ⚠️ not tested — unit tests and gates were run on a headless Linux host only; no macOS/Windows run and no live multi-provider session.

Environment (optional)

Unit tests only (vitest, packages/core). No live provider credentials were used; the repro uses synthetic parts, matching the issue's own statement that no credentials are required to reproduce the conversion behavior.

Risk & Scope

  • Main risk or tradeoff: a false positive would drop a legitimate native signature that happens to parse as {"id": <string>, "encrypted_content": <string>}. Native Anthropic thinking.signature and Gemini thoughtSignature values are opaque tokens that never take that JSON shape, and the recognizer requires both keys to be strings, so this is theoretical rather than observed — and both wires carry an explicit "native signature preserved unchanged" test to catch a regression here. Dropping a foreign payload degrades gracefully: the reasoning text is preserved and the turn is simply sent unsigned.
  • Not validated / out of scope: (1) the issue's "unknown metadata should be treated as incompatible by default" half — with no origin marker on the field, "unknown" can only be decided by guessing at string shapes, and guessing wrong drops legitimate native signatures, which the report itself rules out; that needs the provenance contract in Foundational problem: Content[]/Part[] cannot safely encode per-provider reasoning-replay contracts #8533. (2) No live end-to-end provider-switch capture (needs two provider credentials plus a mid-session switch); no identical-history A/B wire capture, so — consistent with the issue's own evidence boundary — this PR attributes no token delta to signature bytes. (3) Repair of already-persisted Responses sessions is bug(core): switching Responses models or endpoints can make a saved session unusable #9452. (4) llm-chat.ts's private isCompleteResponsesReasoningSignature was deliberately left alone rather than refactored onto the new shared helper, to keep this a narrow bugfix and avoid churn in a file fix(core): preserve every reasoning episode's signature during history consolidation #8260 just touched; that dedup is a reasonable follow-up.
  • Breaking changes / migration notes: none. Same-wire round-trips are lossless and unchanged.
  • No docs/design/ entry: AGENTS.md's design-doc rule ("write one in docs/design/ if the change touches multiple files or involves design decisions. Skip for small bugfixes.") is exempted here — this is a 67-line source bugfix that completes an existing pattern and deliberately makes no provenance design decision, which stays in Foundational problem: Content[]/Part[] cannot safely encode per-provider reasoning-replay contracts #8533.

Linked Issues

Fixes #9453

Related, referenced without a closing keyword: #8533 (versioned provenance contract — where the "unknown by default" half belongs), #9452 (bounded recovery for already-persisted Responses sessions), #8169 (the Responses generator that produces the payload), #8260 (preserves more signatures across history consolidation; its design doc states it "does not repair previously corrupted saved signatures or address context compression and cross-provider provenance", so it does not cover this).

中文说明

这个 PR 做了什么

Part.thoughtSignature 是跨提供商共享的推理重放数据存储位,但它没有任何线路、模型或来源标记,因此每个内容生成器都会用自己的方式解释同一个不透明字符串。本 PR 新增一个识别 OpenAI Responses 重放负载形状的判定函数 —— packages/core/src/utils/thoughtUtils.ts 中的 isResponsesReasoningSignature —— 并在两个会把外来负载转发出去的请求构建点应用它:Anthropic 转换器不再把该负载当作原生 thinking.signature 发出,而是让 thinking 块保持无签名;Gemini 线路则从 stripPartFields 已经构建好的浅拷贝中删除它。两者都保留可见推理文本,且 Gemini 的剥离只作用于发往线路的拷贝,调用方持有的历史仍然保留该负载,之后切回 Responses API 还能继续重放。

为什么需要

#8169 合并后,responses-converter.ts 会为每个 Responses 推理项把 JSON.stringify({ id, encrypted_content }) 写入 thoughtSignature。切换提供商后,这个负载在 Anthropic 线路上会被原样转发(那里唯一的判断是 typeof part.thoughtSignature === 'string'),在 Gemini 线路上也会原样带出,于是一个提供商的不透明重放数据被当成另一个提供商的原生签名发送。该行为已在 main @ 35a702c33 上用合成 Part、无需任何凭据复现,详见 #9453 的复现评论

本 PR 是补全代码库中已有的模式,而不是引入新启发式:responses-converter.ts 在另一个方向上对不可重放的签名已经采用了对称的兜底(丢弃负载、保留可读摘要、记录日志),而 llm-chat.tsisCompleteResponsesReasoningSignature)与 responses-converter.tsdecodeReasoningSignature)本就各有一份私有的同形判定。新增的这个 helper 是该判定的共享导出版本,放在两个调用点都已经引用的叶子模块 thoughtUtils.ts 里。

审阅测试计划

如何验证

两个新的泄漏测试都已确认「加守卫前失败、加守卫后通过」:只回退三个源码文件(git checkout HEAD -- packages/core/src/utils/thoughtUtils.ts packages/core/src/core/anthropicContentGenerator/converter.ts packages/core/src/core/llm-content-generator/llm-content-generator.ts),保留两个测试文件,再用 patch 恢复。

守卫前(源码回退、测试保留): 两个用例失败,断言为 expected '{"id":"rs_68c6c0c9ff5c8191a29b2e78c1a…' to be undefined,实际收到 "{\"id\":\"rs_68c6c0c9ff5c8191a29b2e78c1a40c83\",\"encrypted_content\":\"gAAAAABvcmVhc29uaW5nLXJlcGxheS1wYXlsb2Fk\"}";汇总 Test Files 2 failed (2)Tests 2 failed | 141 passed (143)

守卫后: converter.test.ts + llm-content-generator.test.ts + thoughtUtils.test.ts 汇总 Test Files 3 passed (3)Tests 158 passed (158)

相邻线路回归: responses-converter.test.ts(90) + anthropicContentGenerator.test.ts(175) + openaiContentGenerator/converter.test.ts(234) 汇总 Test Files 3 passed (3)Tests 499 passed (499)

门禁: packages/corenpm run typechecktsc --noEmit)零错误通过;五个改动文件的 prettier --checkeslint 均通过。

每条线路都断言了两侧,而不是只断言「不抛错」:合法的原生签名被原样转发(Anthropic 作为 thinking.signature 发出、Gemini 原样透传),Responses 负载则不存在,同时 thought: true 与可见推理文本保留。第三个测试通过持有对象引用来断言 Gemini 的剥离没有改动调用方的历史 Part。

这两个守卫已覆盖全部线路:OpenAI Chat 转换器从不读取 thoughtSignature(丢签名、留可见文本,不存在泄漏),responses-converter.ts 自己那个方向已经处理过了。

证据(前后对比)

不适用 —— 非用户可见/TUI 改动。线路层面的前后证据即上面「先失败后通过」的断言。

测试平台

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

✅ 已测试 · ⚠️ 未测试 —— 单元测试与门禁仅在无图形界面的 Linux 主机上运行;未做 macOS/Windows 验证,也没有真实的多提供商会话。

环境(可选)

仅单元测试(vitest,packages/core)。未使用任何真实提供商凭据;复现使用合成 Part,与 issue 自身「无需凭据即可复现转换行为」的说明一致。

风险与范围

  • 主要风险/权衡:误判会丢掉一个恰好能解析成 {"id": <string>, "encrypted_content": <string>}合法原生签名。Anthropic 原生 thinking.signature 与 Gemini thoughtSignature 都是不透明 token,永远不会是这个 JSON 形状,且识别函数要求两个键都是字符串,因此这是理论风险而非已观测风险 —— 两条线路都各有一个「原生签名原样保留」的测试来兜住这类回归。丢掉外来负载是优雅降级:推理文本保留,该轮只是以无签名形式发出。
  • 未验证/不在范围内:(1) issue 中「未知元数据应默认视为不兼容」那一半 —— 字段上没有来源标记时,「未知」只能靠猜字符串形状来判定,猜错就会丢掉合法的原生签名,而这正是报告自己排除的情况;这部分需要 Foundational problem: Content[]/Part[] cannot safely encode per-provider reasoning-replay contracts #8533 的来源契约。(2) 没有真实的端到端切换提供商抓包(需要两个提供商凭据加一次会话中途切换),也没有相同历史的 A/B 线路捕获,因此 —— 与 issue 自身的证据边界一致 —— 本 PR 把任何 token 差值归因于签名字节。(3) 已持久化 Responses 会话的修复属于 bug(core): switching Responses models or endpoints can make a saved session unusable #9452。(4) 刻意没有把 llm-chat.ts 里私有的 isCompleteResponsesReasoningSignature 重构到新的共享 helper 上,以保持这是一个范围收窄的 bugfix,并避免在 fix(core): preserve every reasoning episode's signature during history consolidation #8260 刚改过的文件里制造变动;这个去重适合作为后续工作。
  • 破坏性变更/迁移说明:无。同一线路的往返是无损且未改变的。
  • 未新增 docs/design/ 文档:AGENTS.md 的设计文档条款(「如果改动涉及多个文件或包含设计决策,就在 docs/design/ 写一份。小 bugfix 可跳过。」)在此适用豁免 —— 本 PR 是 67 行源码的 bugfix,补全既有模式,并且刻意不做任何来源契约的设计决策(那部分留在 Foundational problem: Content[]/Part[] cannot safely encode per-provider reasoning-replay contracts #8533)。

关联 Issue

Fixes #9453

相关但不带关闭关键字的引用:#8533(版本化来源契约 —— 「默认视为不兼容」那一半应归属此处)、#9452(已持久化 Responses 会话的有界恢复)、#8169(产生该负载的 Responses 生成器)、#8260(在历史合并时保留更多签名;其设计文档声明它「不修复此前已损坏的保存签名,也不处理上下文压缩与跨提供商来源问题」,因此不覆盖本问题)。

`Part.thoughtSignature` doubles as the cross-provider store for
reasoning replay data but carries no origin marker, so each wire
interprets the same opaque string in its own way. Since #8169 landed,
`responses-converter.ts` writes `JSON.stringify({ id,
encrypted_content })` into that field for every Responses reasoning
item. After a provider switch the Anthropic converter forwards any
string-valued `thoughtSignature` as a native `thinking.signature`, and
the Gemini wire passes it through untouched, so a Responses replay
payload goes out as if it were another provider's native signature.

Guard both request-build points with a shared recognizer for that
payload shape, `isResponsesReasoningSignature` in `thoughtUtils.ts`.
The Anthropic converter drops the payload and leaves the thinking block
unsigned; the Gemini wire deletes it from the shallow copy that
`stripPartFields` already builds, so the caller's history keeps the
payload and a later switch back to Responses can still replay it. Both
keep the visible reasoning text.

This completes the fallback `responses-converter.ts` already applies in
the other direction -- drop the unreplayable payload, preserve the
human-readable summary, log the drop -- rather than adding a new
heuristic. A native Anthropic or Gemini signature is an opaque token
that never takes this JSON shape, so legitimate same-wire round-trips
stay lossless; the tests assert both sides on both wires.

Not addressed here: treating *unknown* metadata as incompatible by
default. With no origin marker on the field, "unknown" can only be
decided by guessing at string shapes, and guessing wrong drops
legitimate native signatures. That needs the provenance contract
discussed in #8533.

Tests: packages/core converter.test.ts, llm-content-generator.test.ts
and thoughtUtils.test.ts pass 158/158. Both new leak assertions fail
before the guard with `expected '{"id":"rs_68c6c0c9ff5c8191a29b2e78c1a…'
to be undefined`. Adjacent wire suites (responses-converter, anthropic
generator, openai converter) pass 499/499.

Fixes #9453

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmtvd2hbyvd
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Qwen Triage ended earlyview run. It stopped before finishing; check the run log.

⚠️ Qwen Triage 提前结束 —— 查看运行。未跑完,请查看运行日志。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is present, including the Reviewer Test Plan and the Chinese translation.

Problem: observed, not theoretical. #9453 is a real user report (filed by @netbrah, priority/P2, type/bug, scope/model-switching), still open, and the conversion behaviour was re-confirmed on main @ 35a702c33 with synthetic parts and no credentials. Since #8169 merged, Responses-shaped payloads are actually being written to history rather than only proposed, so this is actionable now. I independently verified the two leak paths exist on main: the Anthropic converter's only guard really is typeof part.thoughtSignature === 'string', and stripPartFields really does pass the field through untouched.

Direction: aligned. A shared thoughtSignature field with no wire/model/origin marker is a genuine contract gap, and this is the narrow legacy-compatibility guard rather than an attempt to solve provenance in a bugfix. Leaving the "unknown metadata is incompatible by default" half to #8533 is the right call — with no origin marker, "unknown" can only be decided by guessing at string shapes, and guessing wrong drops legitimate native signatures, which the report itself rules out. No direct CHANGELOG reference, but the area is clearly relevant.

Size: all five files sit under packages/core/src/**, so this is core. 67 production lines (converter.ts 20, llm-content-generator.ts 12, thoughtUtils.ts 35) against 170 test lines (converter.test.ts 78, llm-content-generator.test.ts 92). Well under the 500-line escalation threshold, no generated/schema files, and the title is fix rather than refactor — so no Stage 0 escalation on size. Noting for the record that you have admin on the repo, which exempts this from the fork-side guardrails anyway.

Approach: the scope feels right, and it matches what I'd have written independently before reading the diff — a shape recognizer in a leaf util, applied at the two request-build points, keeping the visible text and stripping wire-only so history stays replayable on a switch back. No drive-by refactors, no unrelated churn, and the four out-of-scope items are named explicitly rather than quietly dropped. One question worth thinking about, not a blocker: the new isResponsesReasoningSignature is a verbatim logic duplicate of the existing private isCompleteResponsesReasoningSignature in llm-chat.ts (same startsWith('{') early-out, same try/catch, same four key checks). You've called that out as a deliberate follow-up; since the new one is now the exported shared version, folding llm-chat.ts onto it is a ~3-line mechanical change and would stop the codebase carrying three copies of one predicate.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需小标题都在,包括 Reviewer Test Plan 和中文翻译。

问题: 是已观测到的问题,不是理论性加固。#9453 是真实用户报告(由 @netbrah 提出,带 priority/P2type/bugscope/model-switching 标签),目前仍处于 open 状态;该转换行为已在 main @ 35a702c33 上用合成 Part、无需任何凭据重新确认。自 #8169 合并后,Responses 形状的负载是真的在写入历史,而不只是提案阶段,因此现在可以动手。我独立核实了 main 上确实存在这两条泄漏路径:Anthropic 转换器那里唯一的判断确实只有 typeof part.thoughtSignature === 'string',而 stripPartFields 确实原样透传该字段。

方向: 对齐。共享的 thoughtSignature 字段没有线路/模型/来源标记,这是一个真实的契约缺口;本 PR 是范围收窄的旧数据兼容保护,而不是试图在一个 bugfix 里解决来源契约问题。把「未知元数据默认视为不兼容」那一半留给 #8533 是正确的判断 —— 字段上没有来源标记时,「未知」只能靠猜字符串形状来判定,猜错就会丢掉合法的原生签名,而这正是报告自己排除的情况。CHANGELOG 无直接引用,但该领域显然相关。

规模: 五个文件全部位于 packages/core/src/**,属于核心路径。生产代码 67 行(converter.ts 20 行、llm-content-generator.ts 12 行、thoughtUtils.ts 35 行),测试 170 行(converter.test.ts 78 行、llm-content-generator.test.ts 92 行)。远低于 500 行的升级阈值,没有生成/schema 文件,标题是 fix 而非 refactor —— 因此 Stage 0 不因规模升级。另外记录一下:你在本仓库有 admin 权限,因此本来也不受 fork 侧护栏约束。

方案: 范围合理,而且与我读 diff 之前独立想到的写法一致 —— 在叶子 util 里放一个形状识别函数,在两个请求构建点应用,保留可见文本,并且只剥离发往线路的拷贝,这样切回原提供商时历史仍可重放。没有顺手重构,没有无关改动,四项「不在范围内」也明确列出而不是悄悄丢掉。有一个值得考虑的问题(不是阻塞项):新增的 isResponsesReasoningSignaturellm-chat.ts 中已有的私有 isCompleteResponsesReasoningSignature 在逻辑上逐行重复(相同的 startsWith('{') 提前返回、相同的 try/catch、相同的四个键判断)。你已经说明这是刻意留作后续工作;但既然新的这个已经是导出的共享版本,把 llm-chat.ts 改成引用它只是约 3 行的机械改动,可以避免代码库里同时存在三份同一个判定。

风险: 无升级风险信号 —— 改动文件均未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Code review

No critical blockers. The change does what it says, and I checked the load-bearing claims against main rather than taking the description's word for them:

  • The Gemini strip really is wire-only. stripPartFields builds const result = { ...part } before the new delete, so the caller's history part keeps the payload and a later switch back to the Responses API can still replay it. The test holds the part by object identity instead of re-deriving it from the request, which is the right way to pin that.
  • Wire coverage is complete as claimed. I grepped every production read/write of thoughtSignature. The OpenAI Chat converter never touches it; sessionTitle.ts and sessionRecap.ts drop any part carrying thought or thoughtSignature outright, so neither forwards it; anthropicContentGenerator.ts and loggingContentGenerator.ts only read it on inbound responses, not at a request-build point; and responses-converter.ts already handles its own direction. Those two guards really are the only two outbound leaks.
  • The recognizer is defensive and cheap. startsWith('{') short-circuits before JSON.parse for the overwhelmingly common opaque-token case, the parse is wrapped, and it requires both keys to be strings after a null/typeof object narrowing — so it can't throw on arbitrary persisted input. False positives look effectively impossible: native Anthropic and Gemini signatures are base64-shaped opaque tokens that never start with {.
  • The shape assumption is guaranteed upstream, which is worth saying because it's the thing that could have made a shape-based recognizer fragile. flushThoughtEpisode in llm-chat.ts closes a Responses episode the moment a complete payload arrives and resets the accumulator, and its comment states the payload "must never be concatenated with the next reasoning item's payload". So a consolidated episode part carries exactly one complete JSON payload, not a concatenation of two that would fail to parse.
  • thoughtUtils.ts is a genuine leaf — its only import is a type from @google/genai — and responses-converter.ts, the producer of the payload, already imports from it. Correct home for a shared predicate.

One thing worth a second look (non-blocking). The Anthropic half leaves the thinking block unsigned, and there is already machinery downstream that keys off exactly that. dropUnsignedAssistantThinking is enabled for non-DeepSeek, thinking-on, adaptive-thinking models on a non-Anthropic-native base URL — i.e. proxy-hosted Claude — and when it is, dropUnsignedThinkingFromAssistantMessages runs after processContent and treats a block with a missing-or-empty signature as unsigned. Two consequences:

  • On a completed turn it filters the block out entirely (message.content.filter(b => !isUnsignedThinking(b))), so the reasoning text does not reach the wire on that path. "Both keep the visible reasoning text" is true at the converter layer, which is what the new tests assert, but not end-to-end there.
  • If such a block sits on an assistant turn inside an unbroken tool_use/tool_result chain reaching the end of history, that pass throws: "Anthropic-compatible proxy omitted the thinking signature for a tool-use turn that is still in progress. Configure the proxy to preserve thinking signatures, or start a new session with reasoning disabled." Someone who switched from Responses to proxy-hosted Claude mid-tool-use would be told to fix their proxy, when the actual cause is cross-provider history.

My read is that this is coherent integration rather than a defect — that pass is documented for exactly "cross-provider history where non-Anthropic generators only set thought: true", and its own doc says completed turns can safely omit thinking during replay while the active tool loop fails loudly. Before this PR the foreign payload made the block look signed, so it bypassed that designed cleanup and went out as a bogus signature for the remote end to reject; now the common case degrades gracefully and locally. But the new tests call the converter with { enableCacheControl: false } only, so dropUnsignedAssistantThinking is never set and the interaction is untested. Either worth a line in Risk & Scope, or a distinct cause in that error message so the diagnostic isn't misleading.

Two smaller notes: the new helper is a verbatim logic duplicate of the private isCompleteResponsesReasoningSignature in llm-chat.ts (you've already flagged the dedup as a follow-up — agreed, just noting the codebase now carries three copies of one predicate); and the description says thoughtUtils.ts is a module "both call sites already import from", but neither converter.ts nor llm-content-generator.ts imported it on main — this PR adds both imports. The placement is still right, the claim just isn't accurate as written. thoughtUtils.test.ts is also unchanged, so the recognizer's own edge inputs ('{', 'null', '[]', a non-string id, truncated JSON) are only covered indirectly through the two wire tests.

Test evidence

This is an unattended CI run, so per the gate rules I did not build or execute any PR-derived code. Everything below is this PR's own CI, read through the API for the reviewed commit c7087eebdf77ee8ec873fa2ac214823155dd7fd9.

Lint & Static (ubuntu-latest, Node 22.x) is red, and it is not a code defect. The job exited inside check-lint-gate-freshness.mjs, before eslint, prettier, or tsc ran at all — I grepped the full job log and there are zero occurrences of any of them. The gate's own output:

The lint gate changed on 'main' after this branch last incorporated it:

  - .github/workflows/ci.yml: 17990c330da8 fix(desktop): realign the release
    test with the new signing step (#11522) (2026-09-10)

This lane checks out the branch head alone, so its green proves the branch
passes the gate AS THE BRANCH DEFINES IT — with the files above, that gate
is stale. Merge or rebase 'main' into this branch and push to
re-validate under the current gate.

So the fix here is to merge or rebase main and push. Worth being explicit about what that means for the description's gates section: the eslint/prettier/tsc --noEmit results quoted there are the author's local runs on Linux, not CI-verified — this lane never reached them, and the macOS/Windows test lanes are skipped. Not verified: the lint/typecheck claims (gate aborted before them), the unit suite on this commit (still running), and any live multi-provider behaviour (the description itself rules out an end-to-end provider-switch capture).

Test (ubuntu-latest, Node 22.x) and Integration Tests (no-AK, No Sandbox) were still in progress when I fetched this — I don't poll, so I'm reporting them as pending rather than guessing an outcome.

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

Check Conclusion
Lint & Static (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

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

(Bot orchestration jobs — triage, review-pr, authorize, delay-automatic-review — omitted from the table; they aren't this PR's CI.)

Sandboxed verification would settle this: @qwen-code /verify — that a Responses-shaped thoughtSignature in persisted history actually never reaches the Anthropic or Gemini wire end-to-end, including the proxy-hosted adaptive-Claude path where dropUnsignedAssistantThinking is enabled, which no test in this PR exercises (the converter tests pass { enableCacheControl: false } only). You have write access, so the lane is available directly rather than as a sponsored run. /tmux isn't the right lane here — there's no TUI surface, this is a wire-format claim.

中文说明

代码审查

没有发现阻塞性问题。改动与描述一致,而且我是对照 main 核实了几个关键论断,没有直接采信 PR 描述:

  • Gemini 侧的剥离确实只作用于线路。 stripPartFields 在新增的 delete 之前先构造了 const result = { ...part },因此调用方历史里的 Part 仍保留该负载,之后切回 Responses API 还能重放。测试通过持有对象引用来断言这一点,而不是从请求里重新推导,这是正确的钉法。
  • 线路覆盖确实如所述完整。 我 grep 了所有生产代码中对 thoughtSignature 的读写:OpenAI Chat 转换器从不读它;sessionTitle.tssessionRecap.ts 会直接丢弃任何带 thoughtthoughtSignature 的 Part,因此都不会转发;anthropicContentGenerator.tsloggingContentGenerator.ts 只在入站响应里读它,不在请求构建点;responses-converter.ts 自己那个方向已经处理过了。所以这两处确实是仅有的两条出站泄漏路径。
  • 识别函数防御性足够且开销低。 startsWith('{')JSON.parse 之前短路,覆盖了绝大多数不透明 token 的情况;parse 有 try/catch 包裹;并且在 null/typeof object 收窄之后要求两个键都是字符串,因此面对任意已持久化输入都不会抛错。误判基本不可能发生:Anthropic 与 Gemini 的原生签名都是 base64 形状的不透明 token,永远不会以 { 开头。
  • 该形状假设在上游是有保证的 —— 这一点值得说明,因为它正是「基于形状识别」可能变脆弱的地方。llm-chat.ts 中的 flushThoughtEpisode 在收到完整负载的那一刻就关闭该 episode 并重置累加器,其注释明确写着该负载「绝不能与下一个 reasoning item 的负载拼接」。因此合并后的 episode Part 携带的是恰好一个完整 JSON 负载,而不是两个拼接后无法解析的结果。
  • thoughtUtils.ts 是真正的叶子模块 —— 唯一的 import 是来自 @google/genai 的类型 —— 而且负载的生产方 responses-converter.ts 本来就从它导入。作为共享判定函数的归属是正确的。

有一处值得再看一眼(非阻塞)。 Anthropic 这一半让 thinking 块保持无签名,而下游已有专门针对这一点的机制。dropUnsignedAssistantThinking 在「非 DeepSeek + 开启 thinking + 支持 adaptive thinking 的模型 + 非 Anthropic 原生 base URL」时启用,也就是代理托管的 Claude;启用时 dropUnsignedThinkingFromAssistantMessages 会在 processContent 之后运行,并把签名缺失或为空字符串的块视为无签名。两个后果:

  • 在已完成的轮次上,它会把整个块过滤掉(message.content.filter(b => !isUnsignedThinking(b))),因此推理文本在该路径上不会发到线路。"两条线路都保留可见推理文本"在转换器这一层成立(新测试断言的正是这一层),但在该路径上并非端到端成立。
  • 如果这样的块位于一个 assistant 轮次上,而该轮次处于一条直达历史末尾、未中断的 tool_use/tool_result 链中,该逻辑会抛错:"Anthropic-compatible proxy omitted the thinking signature for a tool-use turn that is still in progress. Configure the proxy to preserve thinking signatures, or start a new session with reasoning disabled."。一个从 Responses 切到代理托管 Claude、且正处在 tool-use 过程中的用户,会被告知去修代理配置,而真实原因是跨提供商历史。

我的判断是这属于合理的机制复用,而不是缺陷 —— 那段逻辑的文档说明它正是为「非 Anthropic 生成器只设置 thought: true 的跨提供商历史」而存在,其自身文档也写明已完成轮次在重放时可以安全省略 thinking,而活跃的 tool 循环要显式失败。在本 PR 之前,外来负载会让该块看起来「已签名」,从而绕过这套既有的清理逻辑,把一个伪签名发出去等远端拒绝;现在常见情况会优雅地在本地降级。但新增测试调用转换器时只传了 { enableCacheControl: false },因此 dropUnsignedAssistantThinking 从未被设置,这个交互没有被测试覆盖。建议要么在 Risk & Scope 里补一句,要么给那条错误信息加一个不同的成因分支,避免诊断误导。

两个较小的点:新增的 helper 与 llm-chat.ts 里私有的 isCompleteResponsesReasoningSignature 在逻辑上逐行重复(你已把去重列为后续工作,同意,只是提醒代码库现在有三份同一个判定);另外描述里说 thoughtUtils.ts 是「两个调用点都已从中导入」的模块,但 mainconverter.tsllm-content-generator.ts 都没有导入它 —— 这两个 import 是本 PR 新增的。归属选择依然正确,只是这句话写得不够准确。thoughtUtils.test.ts 也未改动,因此识别函数自身的边界输入('{''null''[]'id 非字符串、JSON 被截断)只是通过两个线路测试间接覆盖。

测试证据

这是无人值守的 CI 运行,因此按门禁规则我没有构建或执行任何来自 PR 的代码。下面全部内容都是通过 API 读取的、针对被审查提交 c7087eebdf77ee8ec873fa2ac214823155dd7fd9 的本 PR 自身 CI 结果。

Lint & Static (ubuntu-latest, Node 22.x)红的,但原因不是代码缺陷。该 job 在 check-lint-gate-freshness.mjs 这一步就退出了,eslint、prettier、tsc 全都没有运行 —— 我 grep 了完整 job 日志,三者出现次数为零。门禁自身的输出见上文英文代码块:main 上的 lint 门禁在本分支最后一次合并之后发生了变化(.github/workflows/ci.yml,提交 17990c330da8#11522,2026-09-10),而该 lane 只检出分支 head,所以它需要 merge 或 rebase main 后重新推送。

这也意味着需要明确说明描述中「门禁」那一节的性质:其中引用的 eslint/prettier/tsc --noEmit 结果是作者在 Linux 上的本地运行,并非 CI 验证 —— 这个 lane 根本没走到那一步,而 macOS/Windows 测试 lane 被跳过了。未验证的部分:lint/typecheck 结论(门禁在其之前中止)、该提交上的单元测试(仍在运行)、以及任何真实多提供商行为(描述本身已排除端到端切换提供商抓包)。

Test (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox) 在我拉取时仍在进行中 —— 我不做轮询等待,因此按 pending 如实报告,不猜测结果。

(表格中的 CI 结论由上方英文表格给出,此处不重复;机器人编排 job 未计入。)

沙箱验证可以把这件事定死:@qwen-code /verify —— 验证已持久化历史中的 Responses 形状 thoughtSignature 端到端确实不会到达 Anthropic 或 Gemini 线路,尤其是启用 dropUnsignedAssistantThinking 的代理托管 adaptive Claude 路径,而本 PR 没有任何测试覆盖该路径(转换器测试只传了 { enableCacheControl: false })。你有写权限,因此可以直接触发该 lane,不需要走 sponsored run。这里 /tmux 不是合适的 lane —— 没有 TUI 界面,这是一个线路格式层面的论断。

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the change itself is right and I'd merge it, but a required check is red on a staleness gate, the unit suite hasn't reported on this commit, and one downstream interaction is untested.

Going back to what I'd have written before opening the diff: this is essentially it. A shape recognizer in a leaf util, applied at the two points that would forward a foreign payload, wire-only on the Gemini side so history stays replayable. I did not find a simpler path that I think the PR missed, and I looked for one — the obvious "just add an origin marker to the field" answer is the right long-term shape and is exactly what #8533 is for, so pulling it into a bugfix would have been the wrong call here.

It solves something real. #9453 came out of a dogfood session that actually failed after a provider switch, and since #8169 merged the payload is being written to real history rather than only proposed, so this stopped being hypothetical. The 67 production lines are all in service of the stated goal — no drive-by refactors, no formatting churn, and the four things deliberately left out are named in the description instead of quietly skipped. That's a PR I can review in one pass, which is the point.

In six months I'd thank you for the comments at both guards — they say why the field is ambiguous and link the issue, so nobody has to re-derive this. The one thing that would annoy me is the third copy of the same predicate; you've flagged the dedup as a follow-up and I agree with leaving it out of this diff, but it should actually happen, because three private copies of one shape check is how the next person adds a fourth.

Where I landed short of approving:

  • A required check is red. Lint & Static failed inside the lint-gate freshness check because .github/workflows/ci.yml moved on main (fix(desktop): realign the release test with the new signing step #11522) after this branch was cut. That's a merge-or-rebase-and-push, not a code problem — but it means the job never reached eslint, prettier, or tsc, so the gates quoted in the description are local Linux runs that CI has not confirmed on this commit.
  • The unit and integration suites were still running when I fetched, and I don't poll. So there is no CI-verified test result for c7087eebdf77ee8ec873fa2ac214823155dd7fd9 yet either way.
  • One interaction is untested and slightly contradicts the description. Leaving the Anthropic thinking block unsigned routes it into the pre-existing dropUnsignedAssistantThinking pass on proxy-hosted adaptive Claude, which removes the block outright on completed turns — so "both keep the visible reasoning text" holds at the converter layer the tests exercise, but not end-to-end on that path. On an active tool-use turn the same pass throws an error that blames proxy configuration. I think routing a foreign unreplayable payload into that pass is the correct integration — it's documented for exactly this cross-provider shape, and the pre-PR behaviour was a bogus signature being rejected remotely, which is worse — but it's a judgement about someone else's machinery that the diff doesn't pin with a test, and it deserves a conscious yes rather than a silent one.

None of that is a reason to request changes, so I haven't: the design is sound and the only hard blocker is mechanical. I'm also not posting an approval this run, and deliberately not leaving a deferred-approval instruction behind — with a required check already red, CI cannot go green on this commit, and the rebase that fixes it moves the head the approval would be pinned to. A standing approve that can only ever be withheld is worse than none.

I'm not escalating to another maintainer or reassigning either. The open question above is answerable by you, and you have admin on the repo, so a second name in the thread would be noise rather than signal.

Next step: rebase or merge main, push, and re-run @qwen-code /triage — with green lint and a completed unit suite this is a 4/5 and an approve from me, and the only thing I'd still want addressed (in this PR or the follow-up) is a line acknowledging the proxy-hosted unsigned-thinking path.

中文说明

Confidence: 3/5 —— 改动本身是对的,我愿意合入;但有一个必需检查因门禁过期而变红,单元测试尚未在该提交上出结果,另外有一处下游交互没有被测试覆盖。

回到我在看 diff 之前会写的方案:本 PR 基本就是那个方案。在叶子 util 里放一个形状识别函数,在两个会把外来负载转发出去的点应用它,Gemini 侧只作用于线路拷贝以保留历史的可重放性。我没有找到更简单而本 PR 遗漏的路径,而且我确实找过 —— 最明显的「给字段加一个来源标记」是正确的长期形态,也正是 #8533 要做的事,所以把它拉进一个 bugfix 里反而是错的。

它解决的是真实问题。#9453 来自一次在切换提供商后确实失败的 dogfood 会话;而且自 #8169 合并后,该负载是在写入真实历史,而不只是提案,所以这件事已经不再是假设性的。67 行生产代码全部服务于既定目标 —— 没有顺手重构,没有格式化噪音,刻意排除的四项也在描述里点名,而不是悄悄跳过。这样的 PR 一遍就能审完,这正是关键。

半年后回看,我会感谢你在两个守卫处写的注释 —— 它们说明了字段为何存在歧义并链接了 issue,因此没人需要重新推导一遍。唯一会让我不舒服的是同一个判定出现了第三份拷贝;你已经把去重列为后续工作,我也同意不要放进这个 diff,但它应该真的被做掉 —— 同一个形状判定存在三份私有拷贝,正是下一个人加出第四份的原因。

我没有直接批准的原因:

  • 有一个必需检查是红的。 Lint & Static 在 lint 门禁新鲜度检查这一步失败,原因是本分支切出之后 main 上的 .github/workflows/ci.yml 发生了变化(fix(desktop): realign the release test with the new signing step #11522)。这需要 merge 或 rebase 后重新推送,不是代码问题 —— 但这也意味着该 job 根本没走到 eslint、prettier 或 tsc,所以描述中引用的那些门禁结果是本地 Linux 运行,CI 尚未在该提交上确认。
  • 我拉取时单元与集成测试仍在运行,而我不做轮询等待。因此目前也还没有针对 c7087eebdf77ee8ec873fa2ac214823155dd7fd9 的、经 CI 验证的测试结果,无论正反。
  • 有一处交互未被测试覆盖,并且与描述略有出入。 让 Anthropic 的 thinking 块保持无签名,会把它送进代理托管 adaptive Claude 上已有的 dropUnsignedAssistantThinking 逻辑;在已完成轮次上该逻辑会整块移除,因此「两条线路都保留可见推理文本」在测试所覆盖的转换器这一层成立,但在该路径上并非端到端成立。在活跃的 tool-use 轮次上,同一逻辑会抛出一个把原因归咎于代理配置的错误。我认为把外来的、不可重放的负载送进这段逻辑是正确的集成 —— 它的文档正是为这种跨提供商形状而写,而改动前的行为是把伪签名发出去被远端拒绝,那更糟 —— 但这是对一个不属于本 diff 的既有机制做出的判断,而 diff 没有用测试把它钉住,它值得一次明确的认可,而不是默默通过。

以上都不构成 request changes 的理由,所以我没有这么做:设计是站得住的,唯一的硬阻塞是机械性的。本次运行我也不发批准,并且刻意不留下延迟批准的指令 —— 既然已有必需检查变红,CI 不可能在该提交上转绿,而修复它的 rebase 会移动批准所要绑定的 head。一个只能被撤回的常设批准,比不发更糟。

我也不会升级给另一位 maintainer 或重新指派。上面那个未决问题你自己就能回答,而且你在本仓库有 admin 权限,因此在线程里再加一个名字只会是噪音而非信号。

下一步:rebase 或 merge main,推送,然后重新运行 @qwen-code /triage —— 只要 lint 转绿、单元测试跑完,这在我这里就是 4/5 并批准;届时我唯一仍希望处理的(在本 PR 或后续 PR 中)是补一句,明确承认代理托管的无签名 thinking 这条路径。

Qwen Code · qwen3.8-max-2026-09-02

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

@doudouOUC doudouOUC 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 at c7087ee. The direction is right and the scope is narrow. I verified the Gemini side really is wire-only: stripPartFields copies at thoughtUtils.ts:354 before the delete at :378, so history stays replayable. One blocking issue.

[Critical] packages/core/src/core/anthropicContentGenerator/converter.ts:623 — dropping the payload but keeping the thinking block produces a block that this same converter classifies as unsigned, and one of its own passes throws on that shape.

  • converter.ts:1178-1184isUnsignedThinking is type === 'thinking' && (typeof value.signature !== 'string' || value.signature.length === 0).
  • converter.ts:1225-1231 — when such a block sits in a turn belonging to the unbroken active tool_use/tool_result chain at the end of history, it throws 'Anthropic-compatible proxy omitted the thinking signature for a tool-use turn that is still in progress…'.
  • That pass is enabled at anthropicContentGenerator.ts:793-797: dropUnsignedAssistantThinking = !isDeepSeek && !!thinking && this.modelSupportsAdaptiveThinking() && !isAnthropicNativeBaseUrl(...) — proxy-hosted Claude 4.6+/5.x with thinking on. That is exactly the configuration where the pre-PR request succeeded, because the payload was assigned unconditionally as signature and the block therefore counted as signed.

So on the lax-proxy path this turns a silent foreign-blob leak into a hard request failure whose message blames the proxy for something the client just did.

Secondary effect on the same pass: on a turn that is not in the active chain the unsigned block is filtered at converter.ts:1234, and if it was the turn's only block the whole assistant message is dropped at :1237 — so "keeps the visible reasoning text" does not hold there either.

Suggest mirroring what responses-converter.ts:544-550 does in the other direction — downgrade or drop the block rather than un-sign it. Worth noting converter.ts:1146-1147 already states that filling signature: '' is what "keeps the wire shape spec-compliant", so an unsigned thinking block is a shape this codebase treats as non-compliant.

[Suggestion] converter.test.ts:4466,4485 — both new Anthropic cases pass only { enableCacheControl: false }, so dropUnsignedAssistantThinking, normalizeAssistantThinkingSignature and injectThinkingOnToolUseTurns are all false and the interaction above is untested. A case with dropUnsignedAssistantThinking: true would pin it.

[Nit] thoughtUtils.ts:56 — third copy of this JSON shape check; llm-chat.ts:1178-1193 is equivalent minus the falsy guard. Fine to defer, flagging only because the new function is the designated shared one.

Checked and clean: both Gemini send paths route through stripUnsupportedFields (llm-content-generator.ts:281,297); openaiContentGenerator/** never reads thoughtSignature; loggingContentGenerator.ts:1165 is logging-only; the recognizer matches the producer exactly, since responses-converter.ts:299-318 returns null when encrypted_content is absent, so every emitted payload carries both keys — no false negatives.

CI note: Lint & Static is red purely on a lint-gate freshness check after ci.yml moved on main, so a rebase rather than a code change. I did not run the suite locally.

…wire-signature

Refresh the branch onto main so the Lint & Static lane passes the
`Check lint gate freshness` gate (main changed .github/workflows/ci.yml
in 17990c3).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtvkxeirvp

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

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-4 duplicate Responses-payload shape check at thoughtUtils.ts:56 — already reported (review 5166618982, @doudouOUC)

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

Not reviewed: reverse audit — stopped before round 4 by the review time budget.

Test Plan (not a blocker): src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查:反向审计——评审时间预算不足,未能开始第 4 轮。

Test Plan(非阻断):src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more。

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

Comment thread packages/core/src/core/anthropicContentGenerator/converter.ts
Comment thread packages/core/src/core/anthropicContentGenerator/converter.test.ts Outdated
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Historical-head review — head moved to 16f5985c0c70e7db721dc85ad08d42f6fdd4be5e while this review was in flight (past the salvage threshold), so the run finished and posted against the head it reviewed: c7087eebdf77ee8ec873fa2ac214823155dd7fd9 (#10110). The next automatic review covers the delta from that anchor. Full log in the workflow run.

中文说明

历史 head 评审 —— 本次评审进行中 head 移动到了 16f5985c0c70e7db721dc85ad08d42f6fdd4be5e(已过 salvage 阈值),因此评审跑完并针对其实际评审的 head c7087eebdf77ee8ec873fa2ac214823155dd7fd9 发布(#10110)。下一次自动评审将从该锚点起评审增量。完整日志见 workflow 运行

An unsigned `thinking` block is exactly the shape the downstream
`dropUnsignedThinkingFromAssistantMessages` pass treats as a proxy
protocol violation, so a foreign Responses reasoning replay payload that
gets dropped must not leave an unsigned thinking block on the wire.
Demote it to a plain text block (keeping the summary when present)
instead, mirroring responses-converter.ts's fallback for unreplayable
signatures.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtvoi040vv

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

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • T2-3 duplicate Responses-payload shape check at thoughtUtils.ts:51 — already reported (review 5166618982, @doudouOUC)

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

Test Plan (not a blocker): src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more.

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

  • packages/core/src/utils/thoughtUtils.ts:56 — [probe] No test for the new shared predicate at its own home
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Test Plan(非阻断):src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more。

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

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

Comment thread packages/core/src/core/anthropicContentGenerator/converter.ts Outdated
Comment thread packages/core/src/core/anthropicContentGenerator/converter.test.ts Outdated
Comment thread packages/core/src/core/anthropicContentGenerator/converter.test.ts Outdated
…nking

Demoting a foreign Responses reasoning replay payload to a visible text block leaked hidden reasoning as assistant prose under stripAssistantThinking (DeepSeek + thinking disabled), because stripThinkingFromAssistantMessages only removes thinking blocks. Thread dropUnsignedAssistantThinking into processContents/processContent and take the text-demotion branch only when it is set; otherwise emit the thinking block unsigned so strip/normalize passes handle it instead of ever attaching the foreign payload as a native signature.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtvz7syqwc

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

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

Not explored to full depth (tool budget reached): "agent 4": none — no check was cut short by the tool ceiling.; "agent 6c": could not confirm the Anthropic SDK's ThinkingBlockParam type (no @anthropic-ai/sdk .d.ts declaring it is installed in this worktree), so the "signature i….

Test Plan (not a blocker): src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more.

Convergence: round 3 posted 2 inline comment(s), 1 of them reported for the first time; the previous round posted 3 (3 new). Findings keep coming back to the same files: packages/core/src/core/anthropicContentGenerator/converter.test.ts (findings in round 2; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未探索到全部深度(达到工具调用预算):"agent 4"none — no check was cut short by the tool ceiling."agent 6c"could not confirm the Anthropic SDK's ThinkingBlockParam type (no @anthropic-ai/sdk .d.ts declaring it is installed in this worktree), so the "signature i…

Test Plan(非阻断):src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more。

收敛情况:第 3 轮发布了 2 条行内评论,其中 1 条是首次提出;上一轮发布了 3 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/core/src/core/anthropicContentGenerator/converter.test.ts(第 2 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)

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

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

wenshao commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Local verification at d9baae67 — merge-ready

I rebuilt this leak locally instead of reading the diff for it. The rig is real HTTP end to end: the real AnthropicContentGenerator (real @anthropic-ai/sdk), the real LlmContentGenerator (real @google/genai) and the real Responses pipeline talk to loopback servers that record the exact request bytes, with merge-base 1961e9744a as the second arm and the same probe file dropped into both worktrees. Nothing in the rig is hand-written: the replay payload is produced by the real Responses generator over a real SSE stream, and it enters history through LlmChat's own stream consolidation.

Verdict: no blocking finding. The payload reaches the wire in 10 of 10 base scenarios and 0 of 10 at this head; every request that carries a legitimate signature is byte-identical between the two arms; history stays replayable; and the Critical raised in review 5166618982 (the dropUnsignedThinkingFromAssistantMessages throw) is genuinely closed — I drove the exact configuration that turns that pass on. Five non-blocking observations at the end, the first of which I think is worth a follow-up.

How the rig is set up

  • Arms — merge-base 1961e9744a vs head d9baae67, identical probe file, verdict = the diff of the two recorded JSON files.
  • Anthropic: a loopback POST /v1/messages server that validates the thinking-block contract and records the raw body. Five provider configurations, each driven through generateContent so the real per-request gating (isDeepSeekAnthropicProvider, modelSupportsAdaptiveThinking, isAnthropicNativeBaseUrl) decides the options — including a native arm, where api.anthropic.com is resolved to 127.0.0.1 inside a private mount namespace so the real isAnthropicNativeBaseUrl branch is exercised rather than a stand-in base URL.
  • Gemini: a loopback generativelanguage endpoint, both send paths (generateContent, generateContentStream).
  • Producer: a loopback Responses SSE endpoint emitting real response.output_item.done reasoning items; the payload used everywhere downstream is whatever that generator actually wrote into thoughtSignature.

1. The leak, and what replaces it

Figure 1

  • The replay payload goes out as a native thinking.signature in 10 of 10 base scenarios and 0 of 10 at this head, and the visible reasoning text survives in all 10.
  • Control — the same 10 scenarios with a native opaque signature in history: 200 OK in both arms, and each of those request bodies is byte-identical between base and head (10/10 Anthropic, 4/4 Gemini). Nothing else moved on the wire.
  • The 400s in the head column are my validator applying the rules this repo already encodes (converter.ts:109-113, the throw at :1250, anthropicContentGenerator.ts:834), not an observed Anthropic response — see observation 1.

2. End-to-end provider switch

Figure 2

Leg 1 is a real Responses turn recorded by the real history writer; leg 2 replays that recorded history on the Anthropic wire. Two things the description asserts, confirmed by measurement rather than by reading:

  • The strip really is wire-only: after the send the caller's history still holds the payload, and feeding that same history back through the real Responses converter still rebuilds the reasoning item with its encrypted_content — a switch back still replays.
  • The recognizer can't miss what the producer writes: with two reasoning items in one turn, the consolidation stores two separate complete payloads (never a concatenation), so startsWith('{') + strict parse has nothing to fall through on. A reasoning item without encrypted_content writes no signature at all — identical in both arms.

3. Do the new tests hold each guard down?

Figure 3

Removing either guard, or the recognizer, fails exactly the tests you would want it to (M1: 1 failure, M2: 3, M3: 4 — M2 reproduces the description's own before/after experiment at this head). But M4 and M5 stay green: loosen isResponsesReasoningSignature to "any JSON object with a string id", or to "any JSON object", and all 161 tests still pass. The false-positive direction — the risk the description itself calls out as the main one — is unpinned, because a native signature isn't JSON at all and so cannot catch a loosened predicate. thoughtUtils.test.ts has no test for the new predicate at its own home (the item deferred in review 5171820161); this is the concrete cost of that gap.

4. Gemini wire, recognizer sweep, gates

Figure 4

A Gemini-native signature attached to a functionCall part is forwarded unchanged, so multi-turn function calling is untouched. 500/500 payloads built with the producer's own encoder are recognized (and the one produced by the live SSE run is too), 0/4000 false positives across base64 and base64url opaque tokens.

Also checked

  • The Critical from review 5166618982, reproduced and then closed. With the demotion disabled — one-line mutation passing false for demoteForeignThoughtToText, i.e. the shape this PR had at c7087ee — the proxy-adaptive + active-tool-chain scenario throws client-side with the exact Anthropic-compatible proxy omitted the thinking signature for a tool-use turn that is still in progress message and the request never leaves the process. At this head the same scenario reaches the wire. The reviewer's finding was real, and the gating commit closes it.
  • The third wire. Drove the real OpenAI Chat generator at a loopback endpoint with the same history: the assistant turn goes out as "content":"Visible answer.","reasoning_content":"<summary>", no signature field, byte-identical in both arms. The description's "no leak there" holds.
  • Wire census. Outside tests, only converter.ts, llm-content-generator.ts and responses-converter.ts read thoughtSignature on a send path. convertLlmRequestToAnthropic is the converter's only public entry and buildRequest its only caller, so both Anthropic send paths are covered by one guard; ContentGenerator has no countTokens, so there is no third Gemini path. loggingContentGenerator.ts:1165 keeps the field, but only for local telemetry.
  • The SDK question left open in the last automatic review: the pinned @anthropic-ai/sdk is 0.36.3 (matches the lockfile) and declares no thinking types at allContentBlockParam is Text | Image | ToolUse | ToolResult | Document. There is no type-level oracle for either shape here, which is why the converter casts; the wire is the only oracle.
  • CI at this head is green, including Lint & Static — the staleness red noted in review 5166618982 is gone. reviewDecision is still CHANGES_REQUESTED, but that is review 5171820161 standing from head a1f7f68; the automatic round-3 review at this head opened no Critical.
  • Gates re-run locally: tsc --noEmit exit 0, prettier --check and eslint --max-warnings 0 on the five changed files exit 0, the three test files 161 passed (the description's 158 predates the last two commits), full packages/core suite head 24984 / base 24977 with the same 3 environmental failures in both arms (head also hit one timing flake while both suites ran in parallel; green on its own re-run).

Non-blocking observations

  1. The demotion is gated on a predicate about proxies, not about the payload. demoteForeignThoughtToText is !!options.dropUnsignedAssistantThinking (converter.ts:268-272), which is true only for a non-DeepSeek adaptive-thinking model with thinking on and a non-native base URL. In the other four configurations the block is emitted unsigned — the shape this file itself treats as non-compliant for Claude (converter.ts:109-113: "the active tool loop fails instead because Claude requires all of its thinking blocks to be passed back complete and unmodified"; anthropicContentGenerator.ts:834: "an empty string cannot replace Claude's opaque signature"). This is not a regression — pre-PR those same requests carried a foreign signature, which is no better — but "the turn is simply sent unsigned" understates it: on api.anthropic.com, on pre-4.6 manual-thinking models and with thinking off, the post-switch turn is still expected to be rejected, just with a different error. DeepSeek is genuinely fine (fillMissingThinkingSignatures turns it into signature: '', which that backend accepts by the comment at converter.ts:1169). If you want one predicate that matches the actual invariant — nothing downstream can make this block valid!options.normalizeAssistantThinkingSignature && !options.stripAssistantThinking covers all four cases and keeps the existing stripAssistantThinking test's "must not leak as visible prose" intent.
  2. On the demote path, an active tool-use turn loses its leading thinking block (text + tool_use, Figure 1 row 2). The client-side throw is correctly avoided, but the guard's comment at converter.ts:298-311 wants that situation to fail loudly rather than continue silently; here it continues, and a strict endpoint answers with its own 400 instead. Worth a sentence in the code or a follow-up issue saying this is the deliberate trade — send a degraded turn, which a lax proxy accepts, rather than hard-fail the session client-side mid tool loop — instead of leaving it implicit.
  3. The demoted summary becomes visible assistant text on that request. It stays thought: true in history, so sessionRecap/sessionTitle's chain-of-thought stripping is unaffected — I checked. But on the wire the model sees its own prior internal summary as ordinary output. Bounded and arguably the right trade; just noting it is a real behavioural change, not only a shape change.
  4. Recognizer near-misses that would still pass through: a payload with a leading space, a truncated payload, or two payloads concatenated. None is reachable from the producer at this head (Figure 2 measures that), so this is documentation, not a defect.
  5. An asymmetry worth knowing about: a reasoning item that never had encrypted_content produces an unsigned thought part, and on the proxy-adaptive arm the drop pass discards its summary text entirely — while a recognized foreign payload now keeps its summary as demoted text. Same in both arms, so pre-existing; the new path is the more preserving of the two.

What I did not verify

No real provider credentials were used, so every 400 above is my local validator applying the contract this repo encodes, not an observed Anthropic/DeepSeek response; the client-side facts (which bytes leave the process) are measured directly. Linux only — no macOS/Windows run, matching the description's own table.

🤖 Generated with Claude Code — Claude Opus 5 (1M context)

中文说明

d9baae67 上的本地验证 —— 可以合入

我没有靠读 diff 下结论,而是在本地把这个泄漏复现了一遍。整套装置是端到端真实 HTTP:真实的 AnthropicContentGenerator(真实 @anthropic-ai/sdk)、真实的 LlmContentGenerator(真实 @google/genai)、真实的 Responses 流水线,全部打到会记录精确请求字节的本地回环服务器上;第二条臂是 merge-base 1961e9744a,同一份探针文件放进两个 worktree。装置里没有任何手写数据:重放负载由真实的 Responses 生成器经真实 SSE 流产生,再由 LlmChat 自己的流式合并逻辑写入历史。

结论:无阻塞问题。 负载在 base 臂 10/10 个场景到达线路,在本 head 上 0/10;所有携带合法签名的请求在两条臂上逐字节一致;历史仍可重放;评审 5166618982 提出的 Critical(dropUnsignedThinkingFromAssistantMessages 抛错)确实已关闭——我驱动了会打开那条 pass 的确切配置。末尾有 5 条非阻塞观察,其中第 1 条建议做个后续处理。

装置构成

  • 两条臂 —— merge-base 1961e9744a 对 head d9baae67,同一份探针,结论取两份记录 JSON 的差异。
  • Anthropic:本地 POST /v1/messages 服务器,校验 thinking 块契约并记录原始 body。五种提供商配置,均通过 generateContent 驱动,让真实的按请求判定(isDeepSeekAnthropicProvidermodelSupportsAdaptiveThinkingisAnthropicNativeBaseUrl)自己决定选项——其中原生臂把 api.anthropic.com 在私有 mount namespace 里解析到 127.0.0.1,从而真正走到 isAnthropicNativeBaseUrl 的原生分支,而不是拿一个替身 baseUrl 糊弄。
  • Gemini:本地 generativelanguage 端点,覆盖两条发送路径(generateContentgenerateContentStream)。
  • 生产者:本地 Responses SSE 端点,发出真实的 response.output_item.done reasoning 项;下游一切使用的负载,就是该生成器实际写进 thoughtSignature 的那个字符串。

1. 泄漏本身,以及取而代之的形态(图 1)

  • 重放负载在 base 臂 10/10 个场景作为原生 thinking.signature 发出,在本 head 上 0/10;可见推理文本 10/10 全部保留。
  • 对照组 —— 同样 10 个场景、历史里换成原生不透明签名:两臂均 200 OK,且每个请求 body 在 base 与 head 之间逐字节一致(Anthropic 10/10、Gemini 4/4)。线路上没有任何其他变化。
  • head 一列中的 400 是我的校验器套用本仓库自己已经编码的规则(converter.ts:109-113:1250 的抛错、anthropicContentGenerator.ts:834),不是真实 Anthropic 的响应——见观察 1。

2. 端到端切换提供商(图 2)

第一段是真实 Responses 轮次由真实历史写入器记录,第二段把这份历史在 Anthropic 线路上重放。描述中的两个断言,用测量而非阅读确认:

  • 剥离确实只作用于线路:发送之后调用方历史仍持有负载,把同一份历史再喂回真实的 Responses 转换器,仍能重建带 encrypted_content 的 reasoning 项——切回去仍可重放。
  • 识别函数不会漏掉生产者写入的东西:一轮里有两个 reasoning 项时,合并逻辑存下两个各自完整的负载(绝不拼接),因此 startsWith('{') + 严格解析没有可漏之处。没有 encrypted_content 的 reasoning 项根本不写签名——两臂一致。

3. 新增测试是否真的按住了每个守卫(图 3)

去掉任一守卫或识别函数,失败的正是应该失败的用例(M1 挂 1 条、M2 挂 3 条、M3 挂 4 条;M2 就是描述里那个「回退源码保留测试」实验在本 head 上的复现)。但 M4 与 M5 依然全绿:把 isResponsesReasoningSignature 放宽成「任何带字符串 id 的 JSON 对象」,甚至「任何 JSON 对象」,161 个测试照样全过。误判方向——也就是描述自己点名的主要风险——没有被任何测试钉住,因为原生签名根本不是 JSON,抓不到被放宽的判定。thoughtUtils.test.ts 里没有针对这个新判定函数本体的测试(即评审 5171820161 中被延后的那一条),这就是该缺口的具体代价。

4. Gemini 线路、识别函数扫描与门禁(图 4)

挂在 functionCall part 上的 Gemini 原生签名被原样转发,多轮函数调用不受影响。500/500 个用生产者自身编码方式构造的负载被识别(真实 SSE 跑出来的那个也被识别),在 4000 个 base64 / base64url 不透明 token 上 0 误判。

另外核对过的

  • 评审 5166618982 的 Critical:先复现,再确认已关闭。 把降级关掉(一行改动,给 demoteForeignThoughtToTextfalse,也就是本 PR 在 c7087ee 时的形态),proxy-adaptive + 活跃工具链这个场景会在客户端抛出原文为 Anthropic-compatible proxy omitted the thinking signature for a tool-use turn that is still in progress 的错误,请求根本没离开进程。在本 head 上同一场景能正常发到线路。评审者的发现是真的,而那个加开关的提交确实把它关掉了。
  • 第三条线路。 用同样的历史驱动真实的 OpenAI Chat 生成器打本地端点:助手轮发出的是 "content":"Visible answer.","reasoning_content":"<摘要>",没有签名字段,两臂逐字节一致。描述里「那条线路不存在泄漏」成立。
  • 线路普查。 测试之外,发送路径上读取 thoughtSignature 的只有 converter.tsllm-content-generator.tsresponses-converter.tsconvertLlmRequestToAnthropic 是转换器唯一的公开入口,buildRequest 是它唯一的调用者,因此一个守卫即覆盖 Anthropic 的两条发送路径;ContentGenerator 没有 countTokens,所以不存在第三条 Gemini 路径。loggingContentGenerator.ts:1165 会保留该字段,但仅用于本地遥测。
  • 上一轮自动评审没查证的 SDK 问题:锁定的 @anthropic-ai/sdk0.36.3(与 lockfile 一致),完全没有 thinking 相关类型——ContentBlockParam 只有 Text | Image | ToolUse | ToolResult | Document。这里两种形态都没有类型层面的判据,这也正是转换器要做类型断言的原因;唯一的判据是线路本身。
  • 本 head 的 CI 是绿的,Lint & Static 也已通过——评审 5166618982 提到的陈旧性红灯已消失。reviewDecision 仍是 CHANGES_REQUESTED,但那是 a1f7f68 时留下的评审 5171820161;本 head 上的第 3 轮自动评审没有未决 Critical。
  • 本地重跑门禁tsc --noEmit 退出 0;五个改动文件的 prettier --checkeslint --max-warnings 0 退出 0;三个测试文件 161 通过(描述里的 158 是最近两个提交之前的数);packages/core 全量套件 head 24984 / base 24977,两臂失败集合相同且均为环境性失败(两套件并行跑时 head 还多挂了一个计时 flake,单独重跑即绿)。

非阻塞观察

  1. 降级的开关挂在「是不是代理」上,而不是挂在「负载是不是外来的」上。 demoteForeignThoughtToText 取值为 !!options.dropUnsignedAssistantThinkingconverter.ts:268-272),只有在非 DeepSeek、自适应思考模型、思考打开且 baseUrl 非原生时才为真。另外四种配置下,块会以无签名形态发出——而这正是本文件自己视为对 Claude 不合规的形态(converter.ts:109-113:「活跃工具循环则直接失败,因为 Claude 要求它的所有 thinking 块原封不动地回传」;anthropicContentGenerator.ts:834:「空字符串无法替代 Claude 的不透明签名」)。这不是回归——改动前同样的请求携带的是外来签名,并不更好——但「该轮只是以无签名形式发出」说轻了:在 api.anthropic.com、在 4.6 之前的手动思考模型上、以及思考关闭时,切换提供商之后的那一轮预期仍会被拒,只是错误变了。DeepSeek 那条臂确实没问题(fillMissingThinkingSignatures 会填成 signature: '',按 converter.ts:1169 的注释该后端接受)。如果想用一个真正对应不变量——下游没有任何 pass 能让这个块变合法——的判定,!options.normalizeAssistantThinkingSignature && !options.stripAssistantThinking 能覆盖全部四种情形,同时保住现有 stripAssistantThinking 用例「不得以可见文本形式泄漏」的意图。
  2. 走降级路径时,活跃工具轮会失去打头的 thinking 块(变成 text + tool_use,见图 1 第 2 行)。客户端抛错确实被规避了,但 converter.ts:298-311 的注释希望这种情形「大声失败而不是静默继续」;现在是静默继续,改由严格端点回 400。建议在代码里或后续 issue 里写一句,说明这是有意的取舍——宁可发出一个降级的轮次(宽松代理会接受),也不要在工具循环中途由客户端硬性报错卡死会话——而不是留成隐含约定。
  3. 被降级的摘要在那次请求里成了可见的助手文本。 它在历史里仍是 thought: true,因此 sessionRecap/sessionTitle 的思维链剥离不受影响——我确认过。但在线路上,模型会把自己之前的内部摘要看成普通输出。范围有限,取舍上也说得通;只是提醒这是真实的行为变化,不只是形态变化。
  4. 仍会被放行的边缘形态:带前导空格的负载、被截断的负载、两个负载拼接。在本 head 上这些都无法从生产者产生(图 2 已测量),因此属于记录,而非缺陷。
  5. 一个值得知道的不对称:本来就没有 encrypted_content 的 reasoning 项会产生无签名的 thought part,在 proxy-adaptive 臂上它的摘要文本会被 drop pass 整个丢掉——而被识别出来的外来负载现在反而会把摘要以降级文本保留下来。两臂一致,属于既有行为;新路径是两者中更保守保留的那个。

未验证的部分

没有使用任何真实提供商凭据,因此上文所有 400 都是我的本地校验器按本仓库编码的契约给出的判定,不是真实 Anthropic/DeepSeek 的响应;客户端侧的事实(哪些字节离开了进程)则是直接测得的。仅 Linux——没有 macOS/Windows 运行,与描述中的表格一致。

wenshao
wenshao previously approved these changes Sep 11, 2026
@wenshao

wenshao commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (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: 102 passed · 0 failed · 102 total

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

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

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

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

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

Verification report

PR #11567 — deep verification report

Verdict: findings — assertions 102 pass / 0 fail / 102 total (assertions.json), verified head d9baae67e83638cda7475c4c2992cd3410215fd7 (git rev-parse HEAD^2), base c46cb85cf21b3b8e8393222fffc670176e0de1da (HEAD^1). The central claim is proven load-bearing by a three-arm A/B (base / intermediate build / head) against real wire oracles, and the PR's own tests are non-vacuous (7/8 mutants killed). Two concrete, measured problems and one coverage gap are reported below; neither blocks the central claim, but both are introduced or exposed by this diff and are worth a reviewer's attention.

中文摘要
  • 结论(verdict)findings。102 条脚本化断言全部通过(0 失败),但发现 2 个实测问题 + 1 个覆盖缺口,值得审阅者关注。
  • A/B 结论:中心主张成立且被证明是承重的。三臂对照(base = HEAD^1、mid = 仅回退 demote 门的中间构建、head = HEAD^2),oracle 为真实线路:Anthropic 侧取真实 convertLlmRequestToAnthropic 产出的 messages[],Gemini 侧取真实回环 HTTP 服务端收到的 JSON body。base 在 8 个外来负载单元格全部泄漏(sig=FOREIGN / socket=FOREIGN),head 全部干净;原生签名与调用方历史在三臂逐字节相同(无回归)。关键行 A3:base 泄漏、mid 抛错("Anthropic-compatible proxy omitted the thinking signature")、head 干净 —— 说明最后一个提交 d9baae67(把 demote-to-text 门控在 dropUnsignedAssistantThinking 上)是承重的:没有它,泄漏修复会把静默泄漏变成生产代理路径上的硬请求失败。见 01-ab-three-arm-wire-table.png
  • findings:(1) 非字符串 thoughtSignature 现在会在 Gemini 请求构建路径抛 TypeError(base 只是转发),而同一 PR 的 Anthropic 调用点有 typeof === 'string' 保护、Gemini 调用点没有;可达性已界定(应用内唯一能产生非字符串的写入点是 anthropicContentGenerator.tssignature_delta 宽松取值,以及损坏/手改的持久化会话)。(2) 新识别器与 decodeReasoningSignature 在前导空白上不一致:Responses 侧认为可重放的负载,守卫不识别,于是原样转发到两条外来线路(head 实测 FORWARDED verbatim)。(3) typeof payload.id === 'string' 子句删除后全部测试仍绿(M8 存活)——普通覆盖缺口,PR 正文的误判论证正依赖该子句。
  • 未覆盖范围:逐提交归因(浅克隆仅可达 1 个提交,快照有 4 个);真实多提供商端到端抓包(无凭据,与 issue 自身边界一致);Anthropic→外来线路方向与"未知默认不兼容"那一半(显式留给 Foundational problem: Content[]/Part[] cannot safely encode per-provider reasoning-replay contracts #8533,已在识别器边界上实测确认本 PR 不改变该方向);prettier/eslint 门禁未跑;全仓测试套件未跑(仅定向 663 条)。

Central claim and A/B

Central claim. After a provider switch, an OpenAI Responses reasoning-replay payload (JSON.stringify({id, encrypted_content})) sitting in the shared Part.thoughtSignature field must not reach a foreign wire — not as Anthropic thinking.signature, not as Gemini thoughtSignature — while the visible reasoning text survives, native signatures pass through unchanged, and the production proxy path (dropUnsignedAssistantThinking) does not fail.

Oracles. Anthropic: the messages[] array produced by the real AnthropicContentConverter.convertLlmRequestToAnthropic — the single non-test caller (anthropicContentGenerator.ts:829) serialises it straight into the request body. Gemini: the exact JSON body received on a real loopback HTTP socket by pointing the real LlmContentGenerator (real @google/genai SDK, no mock) at httpOptions.baseUrl; both entry points (generateContent and generateContentStream) were driven.

Arms. base = HEAD^1 (c46cb85c, recognizer absent — verified: 0 occurrences in its thoughtUtils.ts); mid = head with only the demote-to-text gate reverted (the state of commit a1f7f68f before d9baae67); head = HEAD^2. Each arm ran the identical harness from its own tree; a module.registerHooks provenance guard recorded that every arm loaded all three units from its own tree and 0 modules from any foreign tree (the worktrees have no node_modules, so third-party deps resolve identically from the repo root).

cell (foreign payload) base mid head
A2 Anthropic, bare options sig=FOREIGN (leak) sig=none (unsigned) sig=none (unsigned), text kept
A3 Anthropic, proxy opts + closed tool chain sig=FOREIGN THREW "proxy omitted the thinking signature" no throw, no signature, summary demoted to text, tool_use kept
A4 Anthropic, proxy opts + open tool chain sig=FOREIGN block dropped, summary lost no throw, summary demoted to text
A6 Anthropic, empty reasoning text, proxy opts sig=FOREIGN dropped dropped, no throw
A7 Anthropic, DeepSeek normalization sig=FOREIGN sig="" sig="" (payload replaced by empty signature)
G2 Gemini generateContent, socket body socket=FOREIGN socket=none socket=none, thought:true + text kept
G3 Gemini generateContentStream, socket body socket=FOREIGN socket=none socket=none
G5 Gemini, nested functionResponse.parts socket=FOREIGN socket=none socket=none

No-regression arm (identical on all three arms, byte-for-byte): A1/A8 native Anthropic signature forwarded unchanged (including under the proxy option set with a live tool chain); G1 native Gemini thoughtSignature on the socket unchanged; G4 caller-owned history part held by identity is untouched; A5 under stripAssistantThinking the reasoning is removed without leaking as visible prose.

Why the third arm matters. A3 is the row a two-cell A/B cannot produce: base leaks silently, head is clean, and only the intermediate build throws. dropUnsignedAssistantThinking is the production proxy configuration (!isDeepSeek && thinking && modelSupportsAdaptiveThinking() && !isAnthropicNativeBaseUrl), and dropUnsignedThinkingFromAssistantMessages throws on an unsigned thinking block inside an unbroken active tool chain. So commit d9baae67 is what converts "the leak fix breaks proxy sessions with a hard 400-class error" into "the leak fix degrades gracefully". Captured as 01-ab-three-arm-wire-table.png.

Corrections to the PR description

These are statements about the description, not requests to change code.

  1. The body describes only half of the Anthropic behaviour. It says the converter "leaves the thinking block unsigned instead of emitting the payload as a native thinking.signature". At head the converter also demotes the reasoning summary to visible assistant text when dropUnsignedAssistantThinking is set, and that path is the load-bearing half (see A3 above). The body's "Risk & Scope" never mentions that hidden reasoning becomes visible assistant prose on the proxy path. (Bounded consequence: the demoted text goes back to the same model as replayed history, not to the user; sessionRecap/sessionTitle still filter thought/thoughtSignature parts out of any user-facing projection — verified by reading both filters.)
  2. The helper does not mirror decodeReasoningSignature. The body says llm-chat.ts's isCompleteResponsesReasoningSignature and responses-converter.ts's decodeReasoningSignature "already carry private copies of the same shape check" and the new helper is "the shared, exported version of that check". Measured over 21 boundary inputs: the helper is byte-equivalent to isCompleteResponsesReasoningSignature, but diverges from decodeReasoningSignature on 2 inputs (leading space / leading newline), because the helper adds a startsWith('{') precondition that decodeReasoningSignature does not have. See Finding 2 and 03-recognizer-boundaries-head-vs-fix.png.
  3. thoughtUtils.test.ts is not evidence for the new helper. The body's "After the guard" block lists ✓ src/utils/thoughtUtils.test.ts (15 tests) alongside the two files the PR changes, which reads as direct coverage of isResponsesReasoningSignature. That file is not in the diff and contains zero references to the new export; its 15 tests are pre-existing (parseThought / getThoughtSummary). The new exported recognizer has no direct unit test.
  4. The body's quoted test counts are stale by three in each group. At head: converter.test.ts = 119 (body: 116), responses-converter.test.ts = 93 (body: 90); the two groups total 161 and 502, not 158 and 499. All green either way — the counts predate the final commit.

Findings

F1 (Suggestion) — non-string thoughtSignature now throws a TypeError on the Gemini request-build path; the Anthropic sibling in the same PR is guarded

The new Gemini call site passes the field straight in — isResponsesReasoningSignature(result.thoughtSignature) at llm-content-generator.ts:377 — while the recognizer's first operation is signature.startsWith('{'). The Anthropic call site in the same PR is behind typeof part.thoughtSignature === 'string' (converter.ts:618-619), so the two ends of one PR disagree about whether the field can be trusted.

Reproducing command (harness in harness/probe.mjs, cells H-gemini-nonstring-*):

node --import tsx tmp/pr11567-verify-*/harness/probe.mjs \
  --tree /__w/qwen-code/qwen-code --arm head --observe

Measured, per arm (01-ab-three-arm-wire-table.png, group "NEW DEFECT"):

input base head
thoughtSignature: 1 forwarded to the socket, request proceeds THREW signature.startsWith is not a function, request never reaches the peer
thoughtSignature: true forwarded THREW
thoughtSignature: {id, encrypted_content} forwarded THREW
thoughtSignature: ['{"id":…}'] forwarded THREW

4 of 8 hostile runtime types throw on head; 0 of 8 on base (base has no recognizer). The Anthropic path is identical on all arms (the typeof guard absorbs the value), which is what makes the asymmetry visible.

Reachability, bounded. No in-app writer emits a non-string except one: anthropicContentGenerator.ts:1426-1430 does const signature = (event.delta as { signature?: string }).signature || ''; and passes signature (not blockState.signature, which is stringified by +=) into the emitted chunk — so a non-conforming proxy returning a numeric/boolean signature in signature_delta puts a non-string into history, exactly the cross-provider scenario this PR addresses. The other route is corrupted or hand-edited persisted session history (session restore performs no Part shape validation; sessionService.corruption.test.ts exists precisely because these files are not trusted). A conforming provider cannot produce this. So: not a security issue, not reachable in the happy path — but it is a crash regression introduced by this diff on a request path, where base merely forwarded the garbage.

Minimal suggested fix (measured, not eyeballed)

Make the recognizer honest about its input — it already is the trust boundary for both call sites:

 export function isResponsesReasoningSignature(
-  signature: string | undefined,
+  signature: unknown,
 ): boolean {
-  if (!signature || !signature.startsWith('{')) return false;
+  if (typeof signature !== 'string') return false;
+  if (!signature.trimStart().startsWith('{')) return false;

(trimStart() also closes F2; it keeps the cheap fast-path that avoids JSON.parse on every native base64 signature.) Measured in tmp/fix-tree, same harness, same 27 cells:

  • hostile fixtures go clean: 4/8 throwing inputs → 0/8; all four H-gemini-nonstring-* cells go from THREW to "request reached the peer" (base-equivalent behaviour).
  • benign fixtures byte-identical: 19 of 27 cells identical head↔fix, including all 13 central-claim and native-signature cells (A1A8, G1G5); exactly the 8 targeted cells changed.
  • suite unchanged: 161 passed / 0 failed on fix-tree vs 161 / 0 on head; tsc --noEmit in the main tree with the patch applied = 0 errors (same tree unpatched = 0).
  • divergence vs decodeReasoningSignature: 2 → 0.

Because the suite is green both with and without the patch, it pins nothing along this axis: the fixtures that would go red are a llm-content-generator.test.ts case with thoughtSignature: 1 as unknown as string asserting the request still reaches the peer, and a thoughtUtils.test.ts describe for the recognizer over the boundary set below. The fix should ship with them.

F2 (Suggestion) — the guard and the Responses-side decoder disagree on leading whitespace, so a replayable payload still reaches both foreign wires

decodeReasoningSignature (responses-converter.ts:105-121) has no startsWith('{') precondition; JSON.parse tolerates leading whitespace. The new recognizer requires the value to begin with {. Measured over 21 boundary inputs, 2 disagree (foreignLeadingSpace, foreignLeadingNewline), and the disagreement has a real wire consequence on head:

input head Anthropic head Gemini fix
' ' + payload FORWARDED verbatim as thinking.signature FORWARDED verbatim as thoughtSignature stripped
'\n' + payload FORWARDED verbatim FORWARDED verbatim stripped
payload + ' ' stripped stripped stripped (trailing whitespace was always caught)

Reproducing command: node --import tsx …/probe.mjs --tree <tree> --arm head --observe, cells W-*; captured in 03-recognizer-boundaries-head-vs-fix.png.

Reachability, bounded. The only producer is encodeReasoningSignature = JSON.stringify, which never emits leading whitespace; llm-chat.ts's episode concatenation only ever appends to an existing string. So this is unreachable from the app's own output today and becomes reachable only through hand-edited or migrated persisted sessions (#9452's territory). The reason to care is consistency, not exploitability: the value the Responses side would happily replay is precisely the value the foreign-wire guard lets through, which is the opposite of the invariant the PR states it establishes. The same one-line trimStart() in F1 closes it (measured above: divergence 2 → 0).

F3 (Suggestion, coverage gap from the mutation matrix) — typeof payload.id === 'string' is load-bearing for the PR's own false-positive argument and is pinned by no test

Mutant M8 deletes that clause; the unmutated control is 161/0 and M8 is 161/0 — it survives. It is not dead code (it decides an outcome: {"id":123,"encrypted_content":"g"} is false at head, true under M8) and not redundant defence (no sibling hunk closes the same hazard). It is an ordinary coverage gap. It matters because the PR body's false-positive argument leans on exactly this clause: "the recognizer requires both keys to be strings, so this is theoretical rather than observed". The over-broad direction is well pinned — mutant M4 (recognizer always true) turns 12 tests red, including both native-preservation tests and 10 pre-existing ones — so the risk the author cares about is largely covered; only this one clause is not. Fixture that would pin it: expect(isResponsesReasoningSignature('{"id":123,"encrypted_content":"g"}')).toBe(false).

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

Run in tmp/mut-tree (worktree at head), three test files, restored with git checkout between mutants; captured as 02-mutation-matrix-8-mutants-2-controls.png. Both positive controls are landed in the same files as the mutants they validate.

mutant result classification
control, unmutated 161 passed / 0 failed suite green
M1 Anthropic guard off 3 red (both leak tests + the signature-only test) killed
M2 Gemini guard off 1 red (the Gemini leak test) killed
M3 demote gate off (== a1f7f68f) 1 red ("does not throw on an active tool-use turn") killed — the third commit is pinned by a test
M4 recognizer always true 12 red (2 native-preservation + 10 pre-existing) killed
M5 recognizer always false 4 red (3 Anthropic + 1 Gemini) killed
M6 Gemini strip deletes from the caller part 1 red ("does not mutate the caller-owned history part") killed — that test is non-vacuous
M7 demote path drops the summary text 1 red (the tool-use test asserts the demoted text) killed
M8 id string check removed 0 red survivor → coverage gap (F3)
PC1 strip pass no-op (converter.ts) 4 red control live in the mutated file
PC2 displayName strip no-op (llm-content-generator.ts) 2 red control live in the mutated file

No combination row was needed: M1/M2 close different hazards on different wires, and M3 is the single-hunk row that already shows the layered pair (recognizer + gate) is load-bearing — reverting the recognizer alone (M1/M5) leaks, reverting the gate alone (M3) throws.

Reviewer Test Plan, walked step by step

  1. "Both new leak tests failing before the guard" — reproduced equivalently: M1 (Anthropic guard off) turns exactly the named test red, M2 turns the named Gemini test red. Holds.
  2. "After the guard: 158 passed (158)" — green, but the count is stale: 161 (119 + 27 + 15). Holds modulo the count (correction 4).
  3. "Adjacent wire regression: 499 passed" — green at 502 (93 + 175 + 234). Holds modulo the count.
  4. "npm run typecheck in packages/core passes with zero errors" — measured: tsc --noEmit exit 0, 0 errors. Holds.
  5. "prettier --check and eslint pass on all five changed files"not run by this round (listed under Not covered).
  6. "A third test asserts the Gemini strip does not mutate the caller-owned history part, holding it by object identity" — M6 proves the assertion can fail and fails for the right reason. Holds.
  7. "Wire coverage is complete with these two guards: the OpenAI Chat converter never reads thoughtSignature" — census over packages/*/src (non-test): the only request-build readers are converter.ts:618-650 (guarded) and llm-content-generator.ts:377 (guarded); openaiContentGenerator has zero reads; sessionRecap/sessionTitle filter any part carrying thoughtSignature out; loggingContentGenerator is a decorator over the guarded generators; responses-converter handles its own direction. convertLlmRequestToAnthropic has exactly one non-test caller and both Gemini entry points funnel through stripPartFields. Holds.

Targeted gates

  • packages/core vitest, 6 affected files (converter, llm-content-generator, thoughtUtils, responses-converter, anthropicContentGenerator, openaiContentGenerator/converter): 663 passed (663), 6 files, exit 0 (logs/gate-head-vitest.log).
  • packages/core npm run typecheck: 0 errors (logs/typecheck-head.log).
  • Gate liveness: the mutation matrix's PC1/PC2 rows are the planted violations — the same command that reports 663/0 turns red on a one-line break in each mutated file.

Not covered

  • Per-commit attribution. The checkout is shallow (git rev-parse --is-shallow-repository = true); git rev-list HEAD^1..HEAD^2 returns 1 while the snapshot lists 4 commits. All measurements are against the aggregate HEAD^1..HEAD diff; the intermediate arm approximates commit a1f7f68f's state by reverting one hunk, which is an approximation, not that commit's tree.
  • No live multi-provider E2E capture (needs two provider credentials plus a mid-session switch) — the same boundary the issue itself declares. The Gemini oracle is a real socket but a synthetic peer; this reproduces the wire shape the issue reports, not a live provider-switch session.
  • The Anthropic→foreign direction and the "unknown by default" half are unchanged by this PR and remain deferred to Foundational problem: Content[]/Part[] cannot safely encode per-provider reasoning-replay contracts #8533. Verified as a scope statement, not as a defect: the recognizer returns false for both native signature shapes (measured), so an Anthropic signature still travels on the Gemini wire exactly as before.
  • prettier --check / eslint on the changed files were not run (the PR's own CI covers them).
  • Full-repo test suite not run; only the 6 affected files.
  • No scaling ladder was run: the changed code adds no regex or scanner over untrusted text — one JSON.parse per part, gated behind a startsWith('{') fast path that native base64 signatures never enter, so the added cost per request is bounded by the number of thought parts and is linear.
  • Worktree typechecks are environmental, proven by A/A: any worktree under tmp/ lacks packages/core/node_modules and reports 63 tsc errors (@opentelemetry/*, @lydell/node-pty); the pristine unpatched base worktree reports the identical 63 (logs/typecheck-aa-control.log), none in a file this PR touches, so the patch was typechecked in the main tree instead (0 errors, then restored — final git status empty).
  • countTokens has no Gemini path in LlmContentGenerator (no such method), so there is no third Gemini request-build point to guard.

Methodology

Environment: CI verify container at refs/pull/11567/merge (depth 2); HEAD^1 = base, HEAD^2 = head; the snapshot's baseRefOid (1961e974…) had drifted and was not used. Four trees were used, all nested under the repo so third-party deps resolve identically from the root node_modules: the head checkout, tmp/base-tree (HEAD^1), tmp/mid-tree (head with the demote gate reverted), tmp/mut-tree / tmp/fix-tree (head, mutated/patched per run, restored via git checkout after each). Each harness arm loaded the units under test from its own tree through tsx, with a node:module registerHooks resolve-trace asserting zero modules from any foreign tree; the Gemini oracle is a real node:http loopback server receiving the real SDK's request bodies (SSE for the stream endpoint). Assertions live in harness/assert.mjs and read only recorded evidence (logs/probe-*.json, logs/mut-*.json, gate logs); raw per-arm output is in logs/observe-*.txt, the matrix in logs/mutation-matrix.txt. Scratch worktrees are removed below.

Flakiness gate log

rounds=5 files=2 skipped=0
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/llm-content-generator/llm-content-generator.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/llm-content-generator/llm-content-generator.test.ts


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

verdict: pass
summary: 2 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/converter.test.ts: P (exit 0)
round 1 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 2 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 2 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 3 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 3 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 4 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 4 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 5 · packages/core/src/core/anthropicContentGenerator/converter.test.ts: P (exit 0)
round 5 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)

Evidence images

01-ab-three-arm-wire-table

02-mutation-matrix-8-mutants-2-controls

03-recognizer-boundaries-head-vs-fix

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

Qwen Code · sandboxed verification

…n tests

- Guard isResponsesReasoningSignature against non-string input so a Gemini
  signature_delta number/boolean no longer throws startsWith and crashes.
- Tolerate leading whitespace in the '{' pre-check (matching JSON.parse) so
  whitespace-prefixed replay payloads are still recognized and kept off wires.
- Pin the Anthropic demote/tool-use blocks with exact toEqual and add a
  non-empty summary demote assertion to distinguish demote from drop.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtwe7xfux1

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

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • converter.ts:642 manual-thinking active tool-loop shape — already reported (@wenshao, comment 5628170560, non-blocking observation 2)

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

Test Plan (not a blocker): src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more.

Convergence: round 4 posted 3 inline comment(s), 2 of them reported for the first time; the previous round posted 2 (1 new). The rate of new findings is not falling. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Test Plan(非阻断):src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more。

收敛情况:第 4 轮发布了 3 条行内评论,其中 2 条是首次提出;上一轮发布了 2 条(其中 1 条首次提出)。新发现的产出速度没有下降。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment thread packages/core/src/core/anthropicContentGenerator/converter.test.ts
Comment thread packages/core/src/utils/thoughtUtils.ts
Comment thread packages/core/src/core/llm-content-generator/llm-content-generator.test.ts Outdated
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtwknetexb

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

No blocking issues. LGTM! ✅

Not explored to full depth (tool budget reached): "agent 3b": could not confirm api.anthropic.com's distinct handling of a missing vs. an invalid thinking.signature — node_modules/@anthropic-ai/sdk type definitions wer….

Test Plan (not a blocker): src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more.

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

  • packages/core/src/core/anthropicContentGenerator/converter.test.ts:4434 — [probe] No test couples the recognizer to the producer's encoder
  • packages/core/src/core/anthropicContentGenerator/converter.test.ts:4652 — [probe] DeepSeek thinking-on option pair never exercised with a…
中文说明

无阻断问题。LGTM!✅

未探索到全部深度(达到工具调用预算):"agent 3b"could not confirm api.anthropic.com's distinct handling of a missing vs. an invalid thinking.signature — node_modules/@anthropic-ai/sdk type definitions wer…

Test Plan(非阻断):src/core/anthropicContentGenerator/converter.test.tsno such file or directory; src/core/llm-content-generator/llm-content-generator.test.tsno such file or directory; src/utils/thoughtUtils.test.tsno such file or directory; src/core/openaiResponsesContentGenerator/responses-converter.test.tsno such file or directory; src/core/anthropicContentGenerator/anthropicContentGenerator.test.tsno such file or directory; and 4 more。

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

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

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

Agent-assisted review at 11b347100ce92d653931da3382992ca6e335a268 — no confirmed Critical in the full current-diff static review.

Previous Criticals rechecked against source (not thread flags):

  • Our review 5166618982 / first-round R1-1, foreign replay becoming unsigned thinking and throwing on the active proxy tool chain: fixed. packages/core/src/core/anthropicContentGenerator/converter.ts:268-272,629-655 now forwards the real dropUnsignedAssistantThinking decision and demotes recognized foreign summaries before the unsigned-block pass at :310-312. The production producer is anthropicContentGenerator.ts:793-797,829-845; this is not an unpopulated switch.
  • Second-round R1-1 at the old demotion anchor, hidden reasoning escaping DeepSeek's stripAssistantThinking: fixed for that production path. Demotion is conditional, and DeepSeek's mutually exclusive option set leaves a thinking block for the stripping pass (converter.ts:274-280,1142-1157). The regression fixture also checks that only the visible answer survives.

Coverage: all six changed files and all new test cases; Responses encoder/decoder and signature-only emission, history episode consolidation, both Gemini send paths and nested-part traversal, Anthropic native/proxy/DeepSeek option production and post-processing. The recognizer matches the actual {id, encrypted_content} producer. Gemini removes the field from a copied Part (llm-content-generator.ts:354,377-378), preserving history for a later Responses replay. Chat Completions does not consume thoughtSignature. The new unknown-input guard and the actual later-model-turn test fixture address the subsequent test/robustness concerns.

Boundaries: this isolates the recognized Responses payload; it is not a general signature-authenticity check or a promise that every cross-provider history is accepted by every server. Native/older-Claude paths can still produce unsigned thinking, while manual-thinking proxies have their existing leading-thinking constraints. I did not establish a new regression on those paths and am not elevating the already-discussed limitations. Existing producer-coupling/DeepSeek test and deduplication suggestions remain deferred after the repeated review rounds; no new suggestions.

The author is verified repository admin; maintainer core-gate exemption applies (98 changed non-test production-file lines, including comments). No daemon routes change. Head/base match selection. Validation was static only: no tests, builds, PR code or live provider requests executed. Comment only; no approval implied.

@qqqys

qqqys commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Independent verification at 11b347100ce92d653931da3382992ca6e335a268 — executed A/B wire oracle, merge-ready

Instrument disclosure: this is not a tmux run, and a tmux run would have been vacuous here. Reaching the guarded branch this PR adds needs both an Anthropic-wire content generator and a history part whose thoughtSignature holds a Responses-shaped payload — which only openaiResponsesContentGenerator produces. A single-provider CLI session therefore reaches at most the false branch, so a green tmux transcript would say nothing about the changed behaviour. The oracle used instead is the outbound request body: the real AnthropicContentConverter.convertLlmRequestToAnthropic() is called directly under tsx and the returned messages array — the exact field placed on the wire — is asserted against a marker planted in encrypted_content. No live provider request, no API key.

Arm purity (measured, not assumed)

arm converter.ts llm-content-generator.ts thoughtUtils.ts
base c4d383781494 b811296da5f8 523d455694c1
head a45afabb82e7 d5d65e657e53 52ef4d65e3ae

All three base blobs are byte-identical to main, and all three head blobs match the shas GitHub reports for this head, so the only difference between the two arms is this PR's own production delta. A third configuration forces the new predicate to return false as a sensitivity witness.

Results — 14 arms × 3 configurations

ENC_MARKER_9f3a7c was planted inside encrypted_content; "leak" means the marker reached the serialized body.

arm base head mutant
W1 foreign sig, no options LEAK thinking/SIG:FOREIGN-PAYLOAD clean thinking/NOSIG LEAK
W2 foreign, dropUnsignedAssistantThinking LEAK clean text(summary text) LEAK
W4 foreign, empty text, dropUnsigned LEAK clean (turn emits no block) LEAK
W5 foreign + closed tool chain, dropUnsigned LEAK clean text,tool_use + tool_result LEAK
W6 foreign + dangling tool_use, dropUnsigned LEAK clean text,tool_use LEAK
W8 foreign + DeepSeek pair, tool chain LEAK clean thinking/SIG:EMPTY,tool_use LEAK
W9 foreign + stripAssistantThinking LEAK clean thinking/NOSIG LEAK
W14 foreign + visible answer, DeepSeek pair LEAK clean thinking/SIG:EMPTY,text(visible answ) LEAK
W12 foreign + visible answer, stripAssistantThinking clean clean clean
W3 / W7 / W10 / W11 / W13 — native Anthropic signature controls clean clean clean
  • The leak reproduces at base on 8 of 9 foreign arms and is gone at head on all 9. W12 is already clean at base because the strip pass removes the block wholesale, payload included.
  • The five native-signature controls are byte-identical across base, head and mutant. A legitimate Anthropic thinking.signature still goes on the wire untouched, so the recognizer is not over-broad on the shapes that matter.
  • The mutant witness is sensitive on exactly the eight leak-fixing arms and insensitive on all six controls, so the change in behaviour is attributable to isResponsesReasoningSignature and not to incidental churn in the harness.
  • Both claims in the new code comment were measured rather than believed. fillMissingThinkingSignatures filling signature: '' under DeepSeek normalization is true (W8/W14). The stripAssistantThinking half is incomplete: stripThinkingFromAssistantMessages guards with if (filtered.length === 0) continue;, so when the unsigned thinking block is the assistant message's only block it survives the strip (W9). Non-blocking and not a regression — base behaves identically on that shape and worse, since the block it leaves behind carries the foreign payload. The guard exists to avoid emitting a zero-content assistant message. Worth tightening the wording, nothing more.
  • No arm threw at any configuration, including the tool-chain shapes.

Boundaries of this report

  • The Gemini-side edit (llm-content-generator.ts, delete result.thoughtSignature on a copied part) was not exercised by this harness — it sits in a method on a fully-constructed generator. It is covered by reading only, plus the PR's own tests in the green Test lane.
  • "No arm threw" is bounded by the W7 control, which shows these shapes never reach dropUnsignedThinkingFromAssistantMessages's throw at either arm. The evidence is that the demote path does not create an unsigned block for that pass to trip on, not that the pass's throw is unreachable in general.
  • The recognizer's known false negative on a BOM-prefixed payload (trimStart() strips U+FEFF, then JSON.parse runs on the untrimmed original) is unchanged by this PR and strictly narrower than the pre-PR behaviour, which had no whitespace tolerance at all. Its sole producer is this repo's own JSON.stringify, which never emits a BOM.

CI at this head

150/150 check-runs fetched (items == total_count), reduced to the latest attempt per lane name (33 lanes). Product lanes: 6 success, 3 structurally skipped (Integration Tests (CLI, No Sandbox), Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x)), 0 failure, 0 unfinished. Automation: 5 success, 4 skipped, 0 failure.

⚠️ An unreduced read of this head is misleading and worth flagging for anyone re-checking: earlier attempts leave review-pr = failure and route = cancelled in the list, while their latest attempts are skipped and success. Deduplicating by (name, conclusion) instead of taking the latest attempt per name reports a red lane that is not red.

Verdict

No Critical found. The change removes the measured leak on every arm that had one, preserves every native signature, preserves the visible reasoning summary as text on the demote path, and does not regress the strip/normalize passes. Approving on that basis, as of the state read immediately before posting this comment.


中文说明

验证工具说明:这不是 tmux 报告,而且在本 PR 上 tmux 是无效工具。 要走到新增的判定分支,必须同时满足两个条件:使用 Anthropic 线路的 content generator,并且历史 part 的 thoughtSignature 里带着 Responses 形状的回放载荷(只有 openaiResponsesContentGenerator 会产出)。单 provider 的 CLI 会话最多只能走到 false 分支,因此一份绿色的 tmux 记录对改动的行为毫无证明力。改用出站请求体作为判定依据:在 tsx 下直接调用真实的 AnthropicContentConverter.convertLlmRequestToAnthropic(),对返回的 messages(即真正上线的字段)断言植入 encrypted_content 的标记。全程不发起真实模型请求,也不需要 API key。

A/B 纯净性已实测:base 三个生产文件 blob 与 main 完全一致(c4d383781494 / b811296da5f8 / 523d455694c1),head 三个 blob 与 GitHub 在该 head 上报告的 sha 一致(a45afabb82e7 / d5d65e657e53 / 52ef4d65e3ae),所以两个分支之间的唯一差异就是本 PR 自己的生产代码改动;第三组配置把新判定函数强制 return false 作为敏感性见证。

结果(14 组 × 3 配置):base 在 9 组外来签名场景中有 8 组把载荷泄漏成 thinking.signature,head 全部消除;5 组原生 Anthropic 签名对照组在 base / head / mutant 三种配置下输出完全一致,说明识别函数没有误伤合法签名;变异见证恰好只在 8 组修复场景上敏感、在全部对照组上不敏感,因此行为变化可归因于 isResponsesReasoningSignature 本身。

新注释里的两个断言都做了实测:DeepSeek 归一化下 fillMissingThinkingSignaturessignature: ''(W8/W14);stripAssistantThinking 那半句不完整 —— stripThinkingFromAssistantMessagesif (filtered.length === 0) continue; 保护,当无签名 thinking 块是该 assistant 消息的唯一块时它会留存(W9)。这属于非阻断问题,而且不是回归:base 在同一形状下行为相同且更糟(留存的块还带着外来载荷)。该保护本身是为了避免发出零内容的 assistant 消息。

报告边界:Gemini 侧改动(llm-content-generator.ts 中对副本 part 执行 delete result.thoughtSignature)未被本 harness 执行,仅有阅读覆盖,外加绿色 Test lane 中 PR 自带的测试;"没有任何 arm 抛错"受 W7 对照组限定,只能证明降级路径不会为该 pass 造出无签名块,不能证明该 pass 的抛错路径整体不可达;BOM 前缀导致的漏判为既有行为,本 PR 未改变且严格窄于改动前。

CI:150/150 check-run 全部抓取(items == total_count),按 lane 名取最新一次尝试归约(33 条)。产品 lane:6 绿、3 结构性 skip、0 失败、0 未完成;自动化 lane:5 绿、4 skip、0 失败。提醒:不做最新尝试归约的读法会把早期尝试的 review-pr = failureroute = cancelled 当成当前红灯。

结论:未发现 Critical。改动在每一个原本泄漏的场景上都消除了泄漏,保留了全部原生签名,在降级路径上把可见推理摘要保留为文本,且未使 strip/normalize 两个 pass 回归。据此给出 approve(以本条评论发布前即时读到的状态为准)。

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

Approving on the strength of the executed verification in the comment above (issue comment 5636169545), as of the state read immediately before submitting this review.

Basis, all measured at head 11b347100ce92d653931da3382992ca6e335a268:

  • The leak is real at base and gone at head. An A/B over the outbound request body — the real AnthropicContentConverter.convertLlmRequestToAnthropic() under tsx, no live provider and no API key — reproduces the Responses replay payload reaching the wire as thinking.signature on 8 of 9 foreign-signature arms at the PR base blob, and shows all 9 clean at the head blob. The three base blobs are byte-identical to main and the three head blobs match this head's reported shas, so the only difference between the arms is this PR's own production delta.
  • No over-broad recognition. Five native-Anthropic-signature controls are identical across base, head and a mutant that forces the new predicate to return false; that mutant is sensitive on exactly the eight leak-fixing arms and insensitive on all six controls.
  • CI at this head: 150/150 check-runs, latest attempt per lane, product lanes 6 success / 3 structurally skipped / 0 failure / 0 unfinished.

Not a tmux report, and the reason is in the comment above: a single-provider CLI session can only reach the false branch of the new guard, so a green tmux transcript would carry no information about the changed behaviour. Boundaries are stated there too — the Gemini-side deletion is covered by reading rather than execution, and the "no arm threw" result is bounded by a control showing these shapes never reach the unsigned-thinking pass at either arm.

One non-blocking note, not a request: the new comment in converter.ts says the unsigned block is removed by stripThinkingFromAssistantMessages under stripAssistantThinking, but that pass guards with if (filtered.length === 0) continue;, so the block survives when it is the assistant message's only block. Pre-existing behaviour and strictly better than base, which leaves the same block behind carrying the foreign payload.

@wenshao
wenshao added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit ce79c9f Sep 11, 2026
166 of 169 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(core): model switches can send one provider's reasoning metadata to another provider

6 participants