Skip to content

fix(core): preserve reasoning_content when merging assistant turns - #5815

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
he-yufeng:fix/merge-assistant-reasoning-content
Jun 25, 2026
Merged

fix(core): preserve reasoning_content when merging assistant turns#5815
wenshao merged 1 commit into
QwenLM:mainfrom
he-yufeng:fix/merge-assistant-reasoning-content

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

What this PR does

When two assistant turns end up next to each other, mergeConsecutiveAssistantMessages folds them into one. It already concatenates their text content and joins their tool calls, but it keeps only the reasoning of the first turn and throws away the reasoning of the one it merges in. This change concatenates reasoning_content from both turns the same way the visible content is concatenated, so nothing is lost when the turns collapse.

Why it's needed

reasoning_content is a real field we put on the request: when a model turn carries thoughts but no visible text we deliberately set content: "" (not null) so OpenAI-compatible providers like Ollama don't reject the request with HTTP 400 while reasoning_content is present. The sibling helper cleanOrphanedToolCalls is careful to carry reasoning_content over when it rewrites assistant turns, and the merge step runs right after it (and again after orphan cleanup can make two assistant turns adjacent). So the merge path is the one place that silently drops chain-of-thought that the rest of the pipeline goes out of its way to preserve. After this change the two paths agree.

Reviewer Test Plan

How to verify

Added a unit test under the existing mergeConsecutiveAssistantMessages describe block: two consecutive model turns, each with a thought: true part plus visible text. Expected after merge — content is "First answer.Second answer." and reasoning_content is "First reasoning.Second reasoning.". On main the test fails with the second turn's reasoning missing ("First reasoning."); with the fix it passes. The whole converter.test.ts file (135 tests) stays green.

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

Evidence (Before & After)

N/A — not a user-visible / TUI change.

Tested on

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

Environment (optional)

Unit tests only (vitest), no runtime/sandbox needed.

Risk & Scope

  • Main risk or tradeoff: very low. The new branch only runs when two assistant messages merge and at least one carries reasoning_content; it sets the field only when the combined string is non-empty, so messages without reasoning are byte-for-byte unchanged.
  • Not validated / out of scope: no change to how reasoning_content is produced or consumed elsewhere — purely the merge step.
  • Breaking changes / migration notes: none.

Linked Issues

None.

中文说明

这个 PR 做了什么

当两个 assistant 回合相邻时,mergeConsecutiveAssistantMessages 会把它们合成一个。它已经会拼接文本 content、合并 tool calls,但只保留了第一个回合的 reasoning,把被合并进来的那个回合的 reasoning 丢掉了。这里把两个回合的 reasoning_content 按照合并 content 同样的方式拼接起来,合并时不再丢东西。

为什么需要

reasoning_content 是我们真正会放进请求里的字段:当一个 model 回合只有思考、没有可见文本时,我们故意把 content 设成 "" 而不是 null,这样 Ollama 这类 OpenAI 兼容 provider 在带着 reasoning_content 时不会返回 HTTP 400。兄弟函数 cleanOrphanedToolCalls 在改写 assistant 回合时会特意保留 reasoning_content,而合并这一步紧跟在它后面跑(orphan 清理可能让两个 assistant 回合变相邻,之后还会再跑一次合并)。所以合并这一步是整条流水线里唯一会悄悄丢掉思维链的地方。改完之后两条路径就一致了。

如何验证

在已有的 mergeConsecutiveAssistantMessages describe 块里加了一个单测:两个连续的 model 回合,每个都带一个 thought: true 部分加可见文本。合并后期望 content"First answer.Second answer."reasoning_content"First reasoning.Second reasoning."。在 main 上这个测试会失败,第二个回合的 reasoning 丢失(只剩 "First reasoning.");带上修复后通过。整个 converter.test.ts(135 个测试)保持全绿。

风险

极低。新分支只在两个 assistant 消息合并、且至少一个带 reasoning_content 时才执行,并且只在拼出的字符串非空时才写该字段,所以没有 reasoning 的消息一字节都不变。无破坏性改动。

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: this is a clear bug fix — mergeConsecutiveAssistantMessages silently drops the reasoning of the merged-away turn while cleanOrphanedToolCalls correctly preserves it. The two paths should agree, and this PR makes them agree. Solid alignment with the project's goal of correctly supporting OpenAI-compatible providers (Ollama et al.) where reasoning_content is a real request field. No CHANGELOG reference, but the area is clearly in-scope.

On approach: the diff is exactly what the problem demands — concatenate reasoning_content from both turns the same way content is concatenated, plus a targeted test. No unrelated changes, no scope creep. 53 additions, 0 deletions, 2 files. As minimal as it gets.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:这是一个明确的 bug 修复——mergeConsecutiveAssistantMessages 在合并时悄悄丢掉了被合并回合的 reasoning,而 cleanOrphanedToolCalls 则正确保留了。两条路径应该保持一致,这个 PR 做到了。与项目正确支持 OpenAI 兼容 provider(Ollama 等)的目标完全吻合,这些 provider 确实会用到 reasoning_content 字段。CHANGELOG 中没有直接引用,但该领域显然在项目范围内。

方案:diff 恰好是解决问题所需的最小改动——用与拼接 content 相同的方式拼接 reasoning_content,加上一个有针对性的测试。没有无关改动,没有范围蔓延。53 行新增,0 行删除,2 个文件。非常精简。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review: The fix adds reasoning concatenation right after the existing tool_calls merge, using the same [a, b].filter(Boolean).join('') pattern already used for content merging. Type casts to ExtendedChatCompletionAssistantMessageParam are consistent with how the rest of the function accesses extended fields. The if (combinedReasoning) guard means messages without reasoning are byte-for-byte unchanged. The test is well-scoped — two model turns, each with a thought: true part, verifying both content and reasoning_content after merge. No issues found.

Testing: This is an internal converter function — no user-visible TUI change. Verification is through unit tests. Ran the full converter.test.ts suite (135 tests) in both configurations:

Before (main — test without fix)

 FAIL  converter.test.ts > mergeConsecutiveAssistantMessages > should preserve reasoning_content from every merged assistant turn
AssertionError: expected 'First reasoning.' to be 'First reasoning.Second reasoning.'

Expected: "First reasoning.Second reasoning."
Received: "First reasoning."

 ❯ converter.test.ts:4424:9

 Test Files  1 failed (1)
      Tests  1 failed | 134 skipped (135)
   Duration  7.69s

After (this PR — all 135 tests)

 ✓ src/core/openaiContentGenerator/converter.test.ts (135 tests) 51ms

 Test Files  1 passed (1)
      Tests  135 passed (135)
   Duration  7.65s

The new test fails on main (confirming the bug exists) and passes with the fix (confirming the fix works). All 134 pre-existing tests remain green.

中文说明

代码审查: 修复在现有的 tool_calls 合并之后添加了 reasoning 拼接逻辑,使用了与 content 拼接相同的 [a, b].filter(Boolean).join('') 模式。ExtendedChatCompletionAssistantMessageParam 类型转换与函数中其他地方访问扩展字段的方式一致。if (combinedReasoning) 守卫确保没有 reasoning 的消息完全不变。测试范围合理——两个 model 回合,各带一个 thought: true 部分,验证合并后的 contentreasoning_content。未发现问题。

测试: 这是内部转换器的函数,没有用户可见的 TUI 变化。通过单元测试验证。在两种配置下分别运行了完整的 converter.test.ts(135 个测试):

  • 修复前(main): 新测试失败,期望 "First reasoning.Second reasoning." 但得到 "First reasoning.",确认 bug 存在。
  • 修复后(本 PR): 全部 135 个测试通过,包括新测试。134 个原有测试保持全绿。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a textbook bug fix: real bug confirmed by failing test on main, minimal fix that mirrors an existing pattern in the same function, targeted test added, zero regressions across 135 tests. The reasoning goes: cleanOrphanedToolCalls already preserves reasoning_content when rewriting assistant turns → the merge step (which runs right after, and again after orphan cleanup) should do the same → it didn't → now it does.

My independent proposal would have been identical — concatenate reasoning strings with the same filter(Boolean).join('') approach used for content, place it next to the tool_calls merge block, add a test with two turns each carrying a thought part. The PR's implementation matches this exactly. Nothing to cut, nothing missing.

Approving. ✅

中文说明

这是一个教科书级的 bug 修复:main 上失败的测试确认了真实 bug 的存在,修复方式极简且复用了同一函数中已有的模式,添加了针对性测试,135 个测试零回归。逻辑链条:cleanOrphanedToolCalls 已经在改写 assistant 回合时保留 reasoning_content → 紧随其后运行的合并步骤(orphan 清理后还会再跑一次)应该做同样的事 → 它没有 → 现在它做了。

我的独立方案完全一致——用与 content 相同的 filter(Boolean).join('') 方式拼接 reasoning 字符串,放在 tool_calls 合并块旁边,添加一个两个回合各带 thought 部分的测试。PR 的实现与此完全吻合。没有可砍的,也没有遗漏的。

批准 ✅

Qwen Code · qwen3.7-max

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao wenshao 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! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

请提供一个截图作为 Before & After 的证据,例如在对话中展示 reasoning_content 在合并前后的差异。谢谢!

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

✅ Runtime verification — safe to merge

Verified locally on Linux with a real tmux harness that builds and drives the actual compiled convertGeminiRequestToOpenAI from both sides of the change, plus a vitest A/B of the PR's own test and a mutation check.

  • BEFORE = PR parent 6d6ffc67b (fix absent)
  • AFTER = PR head 2e5152e5 (fix present)
  • MUTATED = AFTER with .join('').join(' ') (to confirm the assertion pins exact concatenation, not just "non-empty")

Layer 1 — A/B on the real compiled function

Drove the shipped OpenAIContentConverter.convertGeminiRequestToOpenAI (esbuild-bundled from each commit, same requestContext fixture as the unit test) across 6 scenarios. Key rows:

● S1  two turns, each thought + text
    BEFORE : reasoning = "First reasoning."                     ← 2nd turn dropped
    AFTER  : reasoning = "First reasoning.Second reasoning."    ← preserved
    MUTATED: reasoning = "First reasoning. Second reasoning."   ← caught by exact-match assert
● S3  first turn has NO reasoning, second DOES  (the silent-drop case)
    BEFORE : reasoning = ∅ (absent)                             ← dropped entirely
    AFTER  : reasoning = "Second thinks."                       ← preserved
● S4  three consecutive reasoning turns
    BEFORE : reasoning = "R1."                                  ← R2, R3 dropped
    AFTER  : reasoning = "R1.R2.R3."                            ← all preserved
● S6  control: two text-only turns, no reasoning
    BEFORE == AFTER  (byte-for-byte unchanged)                  ← no collateral change

All in-scope scenarios pass; messages without reasoning are unchanged.

Layer 2 — vitest A/B (the PR's own test, identical test file on both sides)

Converter under test should preserve reasoning_content from every merged assistant turn
BEFORE (unfixed) × 1 failed
AFTER (fixed) 1 passed

→ The new test genuinely catches the bug it targets — the fix is both necessary and sufficient.

Layer 3 — full converter.test.ts on AFTER (regression)

Test Files  1 passed (1)
     Tests  135 passed (135)

→ No regression; matches the PR's "135 tests stay green" claim exactly.

One out-of-scope observation (NOT a blocker for this PR)

When two reasoning-only turns (thoughts but no visible text, so each is content: "") merge, the merged content collapses to null — the content-merge does [...].filter(Boolean).join('') || null. This is pre-existing (identical on BEFORE and AFTER), and reasoning_content was already present from the first turn, so this PR neither introduces nor worsens the content: null + reasoning_content state that the PR's own rationale cites as the Ollama HTTP-400 trigger. Might be worth a separate follow-up on the content-merge path (mirror the single-message reasoningParts.length > 0 ? '' : null rule) — but it is independent of this change.

Verdict

Correct and safe to merge. The fix does exactly what it claims, the new test has teeth, the full suite stays green, and reasoning-free messages are untouched. 👍

🇨🇳 中文说明(点击展开)

✅ 运行时验证 — 可以合并

Linux 上用真实的 tmux 测试台做了验证:从改动前后两个提交分别编译出真实的 convertGeminiRequestToOpenAI 并直接驱动,另外对 PR 自带的单测做了 A/B,并加了一个变异(mutation)检查。

  • BEFORE = PR 父提交 6d6ffc67b(无修复)
  • AFTER = PR head 2e5152e5(带修复)
  • MUTATED = 在 AFTER 基础上把 .join('') 改成 .join(' ')(验证断言锁定的是精确拼接,而不仅仅是"非空")

第 1 层 — 对真实编译产物做 A/B

用与单测相同的 requestContext,对每个提交 esbuild 出来的真实 OpenAIContentConverter.convertGeminiRequestToOpenAI 跑了 6 个场景,关键几行:

● S1  两个回合,各自 thought + text
    BEFORE : reasoning = "First reasoning."                     ← 第二个回合被丢
    AFTER  : reasoning = "First reasoning.Second reasoning."    ← 保留
    MUTATED: reasoning = "First reasoning. Second reasoning."   ← 被精确断言抓出
● S3  第一个回合无 reasoning、第二个有(静默丢失场景)
    BEFORE : reasoning = ∅(缺失)                              ← 完全丢掉
    AFTER  : reasoning = "Second thinks."                       ← 保留
● S4  连续三个 reasoning 回合
    BEFORE : reasoning = "R1."                                  ← R2、R3 被丢
    AFTER  : reasoning = "R1.R2.R3."                            ← 全部保留
● S6  对照组:两个纯文本回合,无 reasoning
    BEFORE == AFTER(逐字节不变)                                ← 无副作用

所有在范围内的场景都通过;没有 reasoning 的消息保持不变。

第 2 层 — vitest A/B(PR 自带的同一个测试文件,两侧一致)

被测 converter should preserve reasoning_content from every merged assistant turn
BEFORE(未修复) × 失败 1
AFTER(已修复) 通过 1

→ 这个新测试确实能抓到它针对的 bug,说明修复既必要又充分。

第 3 层 — AFTER 上跑完整 converter.test.ts(回归)

Test Files  1 passed (1)
     Tests  135 passed (135)

→ 无回归;与 PR 描述里"135 个测试保持全绿"完全一致。

一个范围外的观察(不是本 PR 的阻塞项)

当两个纯 reasoning回合(只有思考、没有可见文本,即各自 content: "")合并时,合并后的 content 会变成 null —— 因为 content 合并用的是 [...].filter(Boolean).join('') || null。这是改动前就存在的(BEFORE/AFTER 一致),而且 reasoning_content 在第一个回合就已经存在,所以本 PR 既没有引入也没有加重 PR 自己提到的会触发 Ollama HTTP 400 的 content: null + reasoning_content 组合。可以考虑单独跟进 content 合并这条路径(对齐单条消息里 reasoningParts.length > 0 ? '' : null 的规则),但与本次改动无关。

结论

正确,可以合并。 修复行为与描述完全一致,新测试有"牙齿",完整测试套件保持全绿,且不含 reasoning 的消息不受影响。👍

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — reasoning_content preserved on assistant-turn merge

Verified this fix locally with a real, A/B-toggled test run (driven under tmux), at both the source-test level and the compiled, shipped-artifact level. Built from PR head 2e5152e5 · Linux · Node v22.

What the fix does

mergeConsecutiveAssistantMessages (converter.ts) now concatenates reasoning_content from every merged turn, exactly the way it already concatenates content and tool_calls. Before the fix, when two consecutive assistant/model turns were merged, only the accumulator turn's reasoning survived and the merged-away turn's reasoning was silently dropped — inconsistent with cleanOrphanedToolCalls, which already preserves it.

Results

# Check How Result
1 New regression test, fix present vitest (real source) ✅ pass
2 Same test, fix reverted vitest ❌ fails — expected 'First reasoning.' to be 'First reasoning.Second reasoning.'
3 Same test, fix restored vitest ✅ pass
4 Full converter.test.ts suite vitest ✅ 135/135, no regressions
5 Compiled dist artifact, fix ON node harness on real convertGeminiRequestToOpenAI ✅ reasoning merged
6 Compiled dist artifact, fix OFF (rebuilt) same harness ❌ reasoning dropped

Steps 2 and 6 are the key A/B: toggling only the production hunk flips the outcome at both the TS-source level and the compiled-artifact level — so the fix is provably the cause, and the shipped code (not just the test) carries the corrected behavior.

The compiled-artifact harness ran the real production entry point on two realistic histories:

  • A — two consecutive reasoning turns → merged reasoning "First reasoning.Second reasoning."
  • B — a reasoning + tool_call turn followed by a reasoning + answer turn (also exercises the post-cleanOrphanedToolCalls merge) → merged reasoning "Plan: call the tool.Reflect on result."

With the fix removed and the artifact rebuilt, both collapse to only the first turn's reasoning ("First reasoning." / "Plan: call the tool.").

Verdict

Correct, causal, and regression-free — LGTM for merge.

Scope note: this converter is an internal request-pipeline transform (it runs on every request to an OpenAI-compatible provider), so it was verified through its real public entry convertGeminiRequestToOpenAI plus the compiled shipped artifact with A/B toggling, rather than a black-box TUI session — consecutive assistant turns arise from history states (e.g. post-tool-cleanup / context compression) that can't be produced deterministically from interactive input.

中文版(点击展开)

✅ 本地验证 —— 合并 assistant 轮次时保留 reasoning_content

在本地对本 PR 做了真实的、带 A/B 开关的测试验证(在 tmux 中运行),并且同时在「源码测试」层面和「编译后的发布产物」层面进行。基于 PR head 2e5152e5 构建 · Linux · Node v22。

本次修复做了什么

converter.ts 中的 mergeConsecutiveAssistantMessages 现在会把每个被合并轮次的 reasoning_content 拼接起来,方式与它原本拼接 contenttool_calls 完全一致。修复前,当两个连续的 assistant/model 轮次被合并时,只有「累加方」那一轮的 reasoning 会保留,被合并掉的那一轮的 reasoning 会被静默丢弃 —— 这与已经会保留 reasoning 的 cleanOrphanedToolCalls 不一致。

验证结果

# 检查项 方式 结果
1 新增回归测试,带修复 vitest(真实源码) ✅ 通过
2 同一测试,回退修复 vitest ❌ 失败 —— expected 'First reasoning.' to be 'First reasoning.Second reasoning.'
3 同一测试,恢复修复 vitest ✅ 通过
4 完整 converter.test.ts 套件 vitest ✅ 135/135,无回归
5 编译后的 dist 产物,开启修复 对真实 convertGeminiRequestToOpenAI 跑 node 测试脚本 ✅ reasoning 已合并
6 编译后的 dist 产物,关闭修复(重新构建) 同一脚本 ❌ reasoning 被丢弃

第 2 步和第 6 步是关键的 A/B:仅切换生产代码这一处改动,就会在 TS 源码层面和编译产物层面同时改变结果 —— 因此可证明该修复正是问题根因,且最终发布的代码(而不仅仅是测试)确实带上了修复后的行为。

编译产物测试脚本对两段真实历史调用了真实的生产入口:

  • A —— 两个连续的 reasoning 轮次 → 合并后的 reasoning 为 "First reasoning.Second reasoning."
  • B —— 一个 reasoning + tool_call 轮次后接一个 reasoning + answer 轮次(同时覆盖 cleanOrphanedToolCalls 之后的二次合并)→ 合并后的 reasoning 为 "Plan: call the tool.Reflect on result."

去掉修复并重新构建产物后,两段都退化为只剩第一轮的 reasoning("First reasoning." / "Plan: call the tool.")。

结论

修复正确、为问题根因、且无回归 —— 可以合并。

范围说明:该 converter 是请求管线内部的转换逻辑(每次请求 OpenAI 兼容 provider 时都会执行),因此通过其真实公开入口 convertGeminiRequestToOpenAI 加编译后的发布产物、配合 A/B 开关来验证,而非黑盒 TUI 会话 —— 连续的 assistant 轮次来自特定的历史状态(例如工具调用清理后、上下文压缩后),无法通过交互输入稳定复现。

@wenshao
wenshao added this pull request to the merge queue Jun 25, 2026
Merged via the queue into QwenLM:main with commit 8ef6e1a Jun 25, 2026
27 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.

3 participants