Skip to content

fix(core): render a thought part's reasoning instead of the boolean flag - #7866

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
chinesepowered:fix/verbose-thought-part
Jul 28, 2026
Merged

fix(core): render a thought part's reasoning instead of the boolean flag#7866
wenshao merged 1 commit into
QwenLM:mainfrom
chinesepowered:fix/verbose-thought-part

Conversation

@chinesepowered

Copy link
Copy Markdown
Contributor

What this PR does

Makes the verbose rendering of a thought part show the reasoning it carries, rather than the literal string true.

Why it's needed

Part.thought is a boolean flag; the reasoning itself lives in part.text. partToString declared it as thought?: string in a local cast and then interpolated the flag directly, so every thought part rendered verbose as [Thought: true] and the reasoning was thrown away.

The local cast was the only thing letting that compile. The SDK declares thought?: boolean (@google/genai genai.d.ts), createOpenAIReasoningThoughtPart in thoughtUtils.ts builds exactly { text, thought: true }, and every other consumer in the repo reads the text and tests the flag for truthiness — part.thought === true, if (!part.thought), filter((part) => part.text && !part.thought). partToString was the one place treating the flag as the payload.

Testing !== undefined instead of truthiness had a second consequence: a part carrying thought: false is an ordinary part, but it took the thought branch and rendered as [Thought: false] instead of its own text.

Both live verbose callers are affected — partListUnionToString in geminiRequest.ts and the prompt-expansion hook in packages/cli.

Reviewer Test Plan

How to verify

part before after
{ thought: true, text: 'thinking' } [Thought: true] [Thought: thinking]
{ thought: true } [Thought: true] [Thought]
{ thought: false, text: 'ordinary' } [Thought: false] ordinary
createOpenAIReasoningThoughtPart('step one') [Thought: true] [Thought: step one]
{ thought: true, text: 'thinking' }, non-verbose thinking thinking (unchanged)

The fourth row goes through the repo's own constructor rather than a hand-built object, so it pins the fix to the shape the code actually produces. The last row is a guard against over-correcting and passes both before and after.

$ npx vitest run --root packages/core --coverage.enabled=false src/utils/partUtils.test.ts src/core/geminiRequest.test.ts
 Test Files  2 passed (2)
      Tests  60 passed (60)

# with src/utils/partUtils.ts reverted and the tests kept:
AssertionError: expected '[Thought: true]' to be '[Thought: thinking]'
AssertionError: expected '[Thought: true]' to be '[Thought]'
AssertionError: expected '[Thought: false]' to be 'ordinary'

Evidence (Before & After)

N/A — not user-visible in the TUI; the rendering table above is the before/after.

Tested on

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

Environment (optional)

Unit tests only.

Risk & Scope

  • Main risk or tradeoff: the verbose string for a thought part changes shape. Anything matching on the literal [Thought: true] would need updating — I searched and found only the two tests corrected here.
  • Not validated / out of scope: loggingContentGenerator.toPart has the same interpolation and appends [Thought: true] to the text it sends to the CountToken API. It is a different function with different semantics — there the text is not lost, only a useless marker is added — so it belongs in its own PR rather than being folded in here.
  • Breaking changes / migration notes: none.

Linked Issues

None.

中文说明

本 PR 的作用

让 thought part 的 verbose 渲染显示其承载的推理内容,而不是字面量字符串 true

为什么需要

Part.thought 是一个布尔标志,推理内容本身位于 part.text 中。partToString 在局部类型断言里把它声明为 thought?: string,然后直接对该标志做字符串插值,于是每个 thought part 在 verbose 模式下都渲染成 [Thought: true],推理内容被丢弃。

正是这个局部断言让上述代码得以通过类型检查。SDK 将其声明为 thought?: boolean@google/genaigenai.d.ts),thoughtUtils.ts 中的 createOpenAIReasoningThoughtPart 构造的正是 { text, thought: true },而仓库中其他所有使用方都是读取 text、并对该标志做真值判断——part.thought === trueif (!part.thought)filter((part) => part.text && !part.thought)partToString 是唯一把标志当作内容本身的地方。

使用 !== undefined 而非真值判断还带来第二个后果:带有 thought: false 的 part 属于普通 part,却进入了 thought 分支,渲染成 [Thought: false] 而不是它自己的文本。

两个实际使用 verbose 的调用方都受影响——geminiRequest.ts 中的 partListUnionToString,以及 packages/cli 中的 prompt 展开钩子。

审阅者测试计划

如何验证

part 修复前 修复后
{ thought: true, text: 'thinking' } [Thought: true] [Thought: thinking]
{ thought: true } [Thought: true] [Thought]
{ thought: false, text: 'ordinary' } [Thought: false] ordinary
createOpenAIReasoningThoughtPart('step one') [Thought: true] [Thought: step one]
{ thought: true, text: 'thinking' },非 verbose thinking thinking(不变)

第四行走的是仓库自身的构造函数,而非手工拼装的对象,因此能把修复锚定在代码实际产出的形态上。最后一行是防过度修正的保护用例,修复前后均通过。

$ npx vitest run --root packages/core --coverage.enabled=false src/utils/partUtils.test.ts src/core/geminiRequest.test.ts
 Test Files  2 passed (2)
      Tests  60 passed (60)

# 回退 src/utils/partUtils.ts 并保留测试后:
AssertionError: expected '[Thought: true]' to be '[Thought: thinking]'
AssertionError: expected '[Thought: true]' to be '[Thought]'
AssertionError: expected '[Thought: false]' to be 'ordinary'

证据(修复前后对比)

N/A——在 TUI 中不可见;上方的渲染对照表即为修复前后对比。

测试环境

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

运行环境(可选)

仅单元测试。

风险与影响范围

  • 主要风险或权衡:thought part 的 verbose 字符串形态发生变化。任何按字面量 [Thought: true] 做匹配的代码都需要相应调整——我已检索过,只有本 PR 中修正的那两个测试。
  • 未验证 / 不在范围内:loggingContentGenerator.toPart 存在同样的插值问题,会把 [Thought: true] 追加到发往 CountToken API 的文本中。那是一个语义不同的函数——在那里文本并未丢失,只是多了一个无用的标记——因此应当另开 PR,而不是并入本 PR。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

无。

`Part.thought` is a boolean flag and the reasoning lives in `part.text`.
`partToString` declared it as `thought?: string` in a local cast and
interpolated the flag, so every thought part rendered verbose as the
literal `[Thought: true]` and the reasoning was dropped.

The local cast was the only thing making that compile -- the SDK types it
as `thought?: boolean`, `createOpenAIReasoningThoughtPart` builds
`{ text, thought: true }`, and every other consumer reads the text and
tests the flag for truthiness. Drop the cast and follow the same shape.

Testing `!== undefined` also caught a part carrying `thought: false`,
which is an ordinary part: it rendered as `[Thought: false]` rather than
as its own text. Use truthiness, matching the rest of the codebase.

Two existing tests asserted the old output and are corrected. Both were
built from shapes the SDK never emits: `{ thought: 'thinking' }`, which
only type-checks through `as unknown as Part`, and a bare `{ thought:
true }` expected to print its own flag.
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug, clearly demonstrated. partToString interpolates part.thought — a boolean flag — directly into the verbose string, so every thought part renders as [Thought: true] and the actual reasoning in part.text is silently dropped. The before/after table in the PR body makes the mismatch concrete, and the thought: false case (an ordinary part misrouted into the thought branch) is a real second consequence of testing !== undefined instead of truthiness. Not theoretical — the code path is exercised by both live verbose callers (partListUnionToString in geminiRequest.ts and the prompt-expansion hook in packages/cli).

Direction: aligned. This is a rendering-correctness fix in a core utility; well within scope. CHANGELOG has no direct reference, but the area (thought/reasoning rendering) is actively used.

Size: core paths touched (packages/core/src/**). Production logic: ~11 lines (8 additions + 3 deletions in partUtils.ts). Test lines: ~41 (across partUtils.test.ts and geminiRequest.test.ts). No generated/schema files. Well under any threshold.

Approach: the scope feels right — one function fix, tests updated to use realistic Part shapes (including the repo's own createOpenAIReasoningThoughtPart constructor), and the loggingContentGenerator.toPart same-pattern issue is correctly deferred to its own PR rather than folded in. Every edit in the diff serves the stated goal; no drive-by changes.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有清晰论证。partToString 把布尔标志 part.thought 直接做字符串插值,导致每个 thought part 在 verbose 模式下都渲染为 [Thought: true],而 part.text 中的推理内容被丢弃。PR 正文中的 before/after 对照表使问题一目了然;thought: false 的情况(普通 part 被误判为 thought)是使用 !== undefined 而非真值判断的第二个真实后果。这不是理论问题——两个实际的 verbose 调用方(geminiRequest.ts 中的 partListUnionToStringpackages/cli 中的 prompt 展开钩子)都会触发此路径。

方向:对齐。这是核心工具函数中的渲染正确性修复,完全在范围内。CHANGELOG 无直接引用,但该领域(思维/推理渲染)正在被积极使用。

规模:触及核心路径(packages/core/src/**)。生产逻辑:约 11 行(partUtils.ts 中 8 行新增 + 3 行删除)。测试行数:约 41 行(分布在 partUtils.test.tsgeminiRequest.test.ts)。无生成/schema 文件。远低于任何阈值。

方案:范围合理——一个函数修复,测试更新为使用真实的 Part 形状(包括仓库自身的 createOpenAIReasoningThoughtPart 构造函数),loggingContentGenerator.toPart 中的同模式问题被正确地推迟到单独的 PR,而非并入。diff 中的每一处改动都服务于既定目标,无顺手改动。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given "verbose thought rendering shows the boolean flag instead of the reasoning text", I would: (1) remove the thought?: string override from the local cast so the SDK's thought?: boolean is respected, (2) switch part.thought !== undefined to a truthiness check so thought: false parts fall through to their own text, (3) interpolate part.text instead of part.thought, with a bare [Thought] fallback when text is absent, and (4) rewrite the tests to use realistic Part shapes ({ thought: true, text: '...' }) instead of the { thought: 'thinking' } as unknown as Part cast that only compiled through the local override.

Comparison with the diff: the PR does exactly this, and adds one thing I would have considered optional but is genuinely valuable — a test that goes through the repo's own createOpenAIReasoningThoughtPart('step one') constructor, pinning the fix to the shape the codebase actually produces rather than a hand-built object. The non-verbose guard test (passes both before and after) is a sensible over-correction check. No simpler path was missed.

No critical blockers or convention violations found. The code comment explaining why thought is a flag and text carries the reasoning is warranted — the old code's local cast made the wrong mental model compile, so the why is genuinely non-obvious without it. The loggingContentGenerator.ts:1176 same-pattern issue ([Thought: ${part.thought}] appended to CountToken text) is correctly scoped out with a clear rationale.

Downstream consumers verified: partListUnionToString (verbose), userPromptExpansionHook.ts (verbose), client.ts (non-verbose — unaffected), memory/extract.ts (non-verbose — unaffected). No code matches on the literal [Thought: true] string outside the two tests corrected here.

Test Evidence

This is an unattended CI run — PR code is not executed locally. Evidence below is from the PR's own CI checks on the reviewed commit, fetched via the API.

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

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

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

The ubuntu unit/integration suite is still running. macOS and Windows tests were skipped (likely gated on ubuntu passing first). No failures so far. Not verified: full unit suite result (pending), TUI behavior (not user-visible per the PR — the verbose string is used in prompt expansion and token counting, not rendered in the terminal).

中文说明

代码审查

独立方案: 针对"verbose 模式下 thought 渲染显示布尔标志而非推理文本"的问题,我的方案是:(1) 移除局部类型断言中的 thought?: string 覆盖,让 SDK 的 thought?: boolean 生效;(2) 将 part.thought !== undefined 改为真值判断,使 thought: false 的 part 走到自身文本分支;(3) 对 part.text 而非 part.thought 做插值,文本缺失时回退为 [Thought];(4) 将测试改为使用真实的 Part 形状({ thought: true, text: '...' }),取代只能通过 as unknown as Part 编译的 { thought: 'thinking' }

与 diff 的对比: PR 完全采用了上述方案,并额外增加了一个我认为可选但确实有价值的测试——通过仓库自身的 createOpenAIReasoningThoughtPart('step one') 构造函数验证,将修复锚定在代码库实际产出的形状上。非 verbose 保护用例(修复前后均通过)是合理的防过度修正检查。没有遗漏更简单的路径。

未发现关键阻塞项或规范违反。解释 thought 是标志而 text 承载推理内容的代码注释是必要的——旧代码的局部断言让错误的心智模型得以编译,因此 为什么 在没有注释的情况下确实不直观。loggingContentGenerator.ts:1176 中的同模式问题([Thought: ${part.thought}] 追加到 CountToken 文本)被正确地排除在范围外,理由清晰。

已验证下游使用方:partListUnionToString(verbose)、userPromptExpansionHook.ts(verbose)、client.ts(非 verbose——不受影响)、memory/extract.ts(非 verbose——不受影响)。除本 PR 修正的两个测试外,无代码按字面量 [Thought: true] 做匹配。

测试证据

本次为无人值守 CI 运行——不在本地执行 PR 代码。以下证据来自 PR 自身在被审查提交上的 CI 检查,通过 API 获取。

ubuntu 单元/集成测试套件仍在运行中。macOS 和 Windows 测试被跳过(可能以 ubuntu 先通过为前置条件)。目前无失败。未验证:完整单元测试套件结果(待定)、TUI 行为(据 PR 说明非用户可见——verbose 字符串用于 prompt 展开和 token 计数,不在终端渲染)。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, minimal bug fix with a real problem, a correct solution, and thorough tests; would merge without hesitation.

The bug is unambiguous: a local cast overrode the SDK's thought?: boolean with thought?: string, and the verbose branch interpolated the flag instead of the reasoning text. Every thought part rendered as [Thought: true] — the reasoning was silently discarded. The fix is exactly what I would have written: drop the cast override, switch to a truthiness check, interpolate part.text, handle the no-text case. The tests go beyond the minimum — using the repo's own createOpenAIReasoningThoughtPart constructor to pin the fix to the shape the code actually produces, and a non-verbose guard that passes both before and after to prevent over-correction.

The PR is disciplined about scope. loggingContentGenerator.ts has the same interpolation pattern, but it's a different function with different semantics (the text isn't lost there, only a useless marker is added), and the author explicitly defers it. That's the right call — folding it in would muddy a clean, reviewable fix.

If I had to maintain this in six months, I'd thank the author: the code comment explains the non-obvious why (the old cast made the wrong mental model compile), the tests document the expected shapes, and the diff is small enough to revert trivially if anything surfaces.

Approval deferred until CI lands green on 65b063a45d15953c7db0b03682ba7ab016d5f8d3 — the ubuntu unit/integration suite is still running.

中文说明

置信度:5/5 —— 干净、最小化的 bug 修复,问题真实、方案正确、测试充分;毫不犹豫即可合并。

bug 明确无误:局部类型断言将 SDK 的 thought?: boolean 覆盖为 thought?: string,verbose 分支对标志本身而非推理文本做插值。每个 thought part 都渲染为 [Thought: true]——推理内容被静默丢弃。修复方案与我的独立方案完全一致:移除断言覆盖、改用真值判断、对 part.text 插值、处理无文本情况。测试超出了最低要求——使用仓库自身的 createOpenAIReasoningThoughtPart 构造函数将修复锚定在代码实际产出的形状上,还有一个修复前后均通过的非 verbose 保护用例防止过度修正。

PR 在范围控制上很自律。loggingContentGenerator.ts 存在同样的插值模式,但那是语义不同的函数(文本并未丢失,只是多了一个无用标记),作者明确将其推迟。这是正确的选择——并入会模糊一个干净、可审查的修复。

如果六个月后需要维护这段代码,我会感谢作者:代码注释解释了不直观的 为什么(旧断言让错误的心智模型得以编译),测试记录了预期形状,diff 足够小,万一出现问题可以轻松回退。

审批推迟至 CI 在 65b063a45d15953c7db0b03682ba7ab016d5f8d3 上全部通过——ubuntu 单元/集成测试套件仍在运行中。

Qwen Code · qwen3.8-max-preview

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@wenshao wenshao 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 issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

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

@wenshao

wenshao commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real local build + runtime A/B

I built this locally and exercised it against real code paths rather than re-reading the diff. Verdict: the fix is correct, the tests are bound to it, and the blast radius is exactly the thought-flagged verbose branch — nothing else moves. Safe to merge.

Setup — PR head 65b063a45d15953c7db0b03682ba7ab016d5f8d3, merge-base with main 3209b89f, Linux, Node v22.22.2, vitest 3.2.4. The "before" side of every A/B below is a real second build of packages/core with only src/utils/partUtils.ts reverted to merge-base; tests, harnesses and every other file are byte-identical across the two runs.

1 · The tests are bound to the fix

npx vitest run --root packages/core src/utils/partUtils.test.ts src/core/geminiRequest.test.ts — 60/60 pass on the PR head. Revert only partUtils.ts and keep the tests: 6 fail, 54 still pass.

unit A/B

Two things worth noting. First, the PR body says the revert produces 3 assertion failures — it actually produces 6; the two geminiRequest.test.ts cases fail as well, so the A/B is stronger than advertised. Second, the non-verbose guard test is among the 54 that keep passing, which is exactly what it was written for: the tests pin the new behaviour without over-claiming.

2 · Wire-level: real SSE stream → real generator → real render

The most useful question for a rendering fix is whether the shape it assumes is the shape that actually arrives. So: a fake OpenAI-compatible server streams reasoning_content over HTTP into the real built OpenAIContentGenerator, and the Part objects it produces are handed to the built partListUnionToString. No Part is hand-built anywhere in this harness.

wire-level A/B

The part that comes off the wire is { text: '…', thought: true } with typeof thought === 'boolean', and isOpenAIReasoningThoughtPart() returns true on it — it is the repo's own createOpenAIReasoningThoughtPart output, reached through the streaming path, not a fixture. Against the merge-base build the reasoning text is gone from the rendered string; against this PR it survives. That settles the premise: thought?: string in the old local cast was contradicted by the value that really flows through.

3 · Cross-package: the CLI's UserPromptExpansion serializer

packages/cli/src/utils/userPromptExpansionHook.ts is imported unchanged; only the built core it resolves through is swapped. Controls (plain prompt, image part) are byte-identical in both runs.

cross-package A/B

4 · Full differential — what actually changed

Every Part shape partToString branches on, in both modes, through both real builds:

differential

40 shape/mode combinations: 9 changed, 31 byte-identical. Zero non-verbose rows changed; every changed row is verbose: true and thought-flagged. That bounds the regression surface precisely — the two non-verbose in-repo callers (client.ts, memory/extract.ts) cannot be affected.

Three rows the PR's table doesn't list, all neutral-or-better:

shape merge-base this PR
{ thought: false, inlineData } [Thought: false] <image/png> misrouting fixed for non-text parts too
{ thought: false } (no text) [Thought: false] "" falls through, consistent with the non-verbose path
{ thought: true, functionCall } [Thought: true] [Thought] equally lossy either way — no regression

5 · Gates

check result
PR's own tests 60/60 pass
full packages/core suite 17,931 passed, 13 skipped, 2 failed — both unrelated (see below)
packages/cli userPromptExpansionHook.test.ts 12/12 pass
tsc --noEmit -p packages/core clean
eslint --max-warnings 0 on the 3 changed files clean

The 2 failures are in src/services/session-writer-lease.test.ts ("classifies an unreadable owned lock as unavailable" and the retried-cleanup case). They chmod 0o000 a lock file and expect a read to reject — which cannot happen when the suite runs as uid 0, as it does on my box. That file has no reference to partUtils and is untouched by this PR; CI runs as non-root and is green on 65b063a.

6 · Blast radius today — worth stating plainly

There are exactly two verbose call sites, and I traced both:

  • partListUnionToString (core/geminiRequest.ts) — zero in-repo callers; it exists only as public API (packages/core/src/index.ts re-exports both it and partToString).
  • serializeUserPromptExpansionPrompt (cli/src/utils/userPromptExpansionHook.ts) — feeds the UserPromptExpansion hook. I walked every submit_prompt producer that reaches it (custom TOML commands via the prompt-pipeline processors, skills, bundled skills, MCP prompts — which stringify to JSON text — and the plan/goal/dream/remember/init/statusline/model commands). All of them carry user-prompt material: text and @-file / media parts. None emits a thought part.

So no in-repo path feeds a thought part into a verbose serializer today, and this PR changes nothing a user currently sees — which matches the PR body's own "N/A — not user-visible in the TUI", and is why there is no TUI before/after to screenshot. What it does fix is the exported API: any SDK consumer or extension that renders model-response parts verbose is silently losing reasoning right now, and any future in-repo caller would inherit that. Cheap, well-tested, and it removes a local cast that made the wrong mental model type-check. Worth taking on those grounds.

7 · One correction for the follow-up PR

The deferral of loggingContentGenerator.toPart is the right call — text is preserved there, only a useless [Thought: true] marker is appended — but the stated reason is off. That marker does not reach the CountToken API (countTokens just delegates to the wrapped generator); toPart is called from toContentslogApiRequest, so it lands in the ApiRequestEvent telemetry payload. The in-code comment above it says "CountToken API compatibility" and is stale, which is presumably where the description came from. Worth correcting when that follow-up lands.

中文说明

维护者验证 —— 本地真实构建 + 运行时 A/B

我在本地做了真实构建,并让改动跑在真实代码路径上,而不是只读 diff。结论:修复正确,测试确实绑定在这个修复上,影响面精确地局限于 thought 标志位的 verbose 分支,其余全部不变。 可以合并。

环境 —— PR head 65b063a45d15953c7db0b03682ba7ab016d5f8d3,与 main 的 merge-base 为 3209b89f,Linux,Node v22.22.2,vitest 3.2.4。下面每组 A/B 的 "before" 一侧都是packages/core 的第二次真实构建,其中src/utils/partUtils.ts 回退到 merge-base;测试、验证脚本以及其他所有文件在两次运行中逐字节相同。

1 · 测试确实绑定在修复上

npx vitest run --root packages/core src/utils/partUtils.test.ts src/core/geminiRequest.test.ts —— PR head 上 60/60 通过。仅回退 partUtils.ts、保留测试:6 个失败,54 个仍然通过。(截图见英文第 1 节)

有两点值得说明。其一,PR 描述称回退后产生 3 个断言失败,实际是 6 个——geminiRequest.test.ts 的两个用例同样失败,所以 A/B 比描述中更有力。其二,非 verbose 的保护用例位于仍然通过的那 54 个之中,这正是它被写出来的目的:测试锁定了新行为,又没有过度断言。

2 · 链路级:真实 SSE 流 → 真实 generator → 真实渲染

对一个渲染修复来说,最值得回答的问题是:它所假设的数据形态,是否就是实际到达的形态。因此:一个仿真的 OpenAI 兼容服务通过 HTTP 流式下发 reasoning_content,进入真实构建的 OpenAIContentGenerator,其产出的 Part 对象再交给构建后的 partListUnionToString。整个 harness 中没有任何一个 Part 是手工拼装的。

从链路上下来的 part 是 { text: '…', thought: true }typeof thought === 'boolean',且 isOpenAIReasoningThoughtPart() 对其返回 true——它就是仓库自身 createOpenAIReasoningThoughtPart 的产物,经由流式路径抵达,而非测试夹具。在 merge-base 构建下,推理文本在渲染结果中消失;在本 PR 下则得以保留。这就坐实了前提:旧的局部断言 thought?: string 与真实流过的值相矛盾。

3 · 跨包:CLI 的 UserPromptExpansion 序列化器

packages/cli/src/utils/userPromptExpansionHook.ts 原封不动地被引入,只切换它所解析到的构建后 core。对照项(纯文本 prompt、图片 part)在两次运行中逐字节一致。

4 · 完整差分——到底改变了什么

partToString 会分支处理的每一种 Part 形态,在两种模式下、分别跑过两个真实构建:

40 组 形态/模式 组合:9 组改变,31 组逐字节相同。非 verbose 行改变数为 0;所有发生改变的行都同时满足 verbose: true 且带 thought 标志。 这精确地界定了回归面——仓库内两个非 verbose 调用方(client.tsmemory/extract.ts)不可能受影响。

有三行是 PR 的表格未列出的,且均为中性或更优:

形态 merge-base 本 PR
{ thought: false, inlineData } [Thought: false] <image/png> 非文本 part 的误路由同样被修正
{ thought: false }(无 text) [Thought: false] "" 落到后续分支,与非 verbose 路径一致
{ thought: true, functionCall } [Thought: true] [Thought] 两侧同样有损——不构成回归

5 · 各项检查

检查项 结果
PR 自带测试 60/60 通过
packages/core 全量测试 17,931 通过,13 跳过,2 失败——均无关(见下)
packages/cliuserPromptExpansionHook.test.ts 12/12 通过
tsc --noEmit -p packages/core 干净
对 3 个改动文件执行 eslint --max-warnings 0 干净

那 2 个失败位于 src/services/session-writer-lease.test.ts("classifies an unreadable owned lock as unavailable" 以及重试清理那一例)。它们把锁文件 chmod 0o000 后期望读取被拒绝——而当测试以 uid 0(我这台机器上正是如此)运行时,这不可能发生。该文件完全没有引用 partUtils,本 PR 也未触及;CI 以非 root 运行,在 65b063a 上是绿的。

6 · 当前的实际影响面——有必要说清楚

verbose 调用点恰好有两个,我都做了追踪:

  • partListUnionToStringcore/geminiRequest.ts)——仓库内零调用方;它仅作为公开 API 存在(packages/core/src/index.ts 同时导出了它和 partToString)。
  • serializeUserPromptExpansionPromptcli/src/utils/userPromptExpansionHook.ts)——为 UserPromptExpansion 钩子提供输入。我走查了所有能到达它的 submit_prompt 产出方(经 prompt-pipeline 处理器的自定义 TOML 命令、skills、内置 skills、MCP prompts——其结果被序列化为 JSON 文本——以及 plan/goal/dream/remember/init/statusline/model 等命令)。它们承载的都是用户 prompt 素材:文本与 @ 文件/媒体 part,没有任何一个会产出 thought part。

因此当前仓库内没有任何路径会把 thought part 送入 verbose 序列化器,本 PR 也不会改变用户当下所见——这与 PR 描述自己写的 "N/A——在 TUI 中不可见" 一致,也正是没有 TUI 前后对比截图可拍的原因。它真正修好的是导出的 API:任何以 verbose 方式渲染模型响应 part 的 SDK 使用方或扩展,现在都在静默丢失推理内容,未来仓库内新增的调用方也会继承这个问题。成本低、测试充分,并且移除了一个让错误心智模型得以通过类型检查的局部断言。基于这些理由,值得合入。

7 · 给后续 PR 的一处更正

推迟处理 loggingContentGenerator.toPart 是正确的判断——那里文本并未丢失,只是多追加了一个无用的 [Thought: true] 标记——但陈述的理由有偏差。该标记并不会进入 CountToken API(countTokens 只是转发给被包装的 generator);toPart 是由 toContentslogApiRequest 调用的,因此它落在 ApiRequestEvent 的遥测负载中。其上方的代码注释写着 "CountToken API compatibility",已经过时,PR 描述大概率源自该注释。后续 PR 落地时值得一并更正。

@wenshao
wenshao added this pull request to the merge queue Jul 28, 2026
Merged via the queue into QwenLM:main with commit fceb755 Jul 28, 2026
59 checks passed
@yiliang114

yiliang114 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Recovered after restart; restarting task

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

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.

4 participants