Skip to content

fix(core): Parse tagged thinking for GLM responses#6033

Merged
wenshao merged 11 commits into
QwenLM:mainfrom
VectorPeak:codex/glm-tagged-thinking
Jun 30, 2026
Merged

fix(core): Parse tagged thinking for GLM responses#6033
wenshao merged 11 commits into
QwenLM:mainfrom
VectorPeak:codex/glm-tagged-thinking

Conversation

@VectorPeak

@VectorPeak VectorPeak commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What this PR does

This PR enables tagged thinking parsing for DashScope OpenAI-compatible glm-* requests, so <think>...</think> content returned in the normal content field is converted into thought parts instead of being displayed as normal assistant output.

The parsing decision now follows the effective request model (request.model || contentGeneratorConfig.model). This matters when a caller overrides a non-GLM default config to a GLM request model, or overrides a configured GLM model to a non-GLM model.

It also keeps provider-supplied reasoning_content / reasoning when tagged parsing is enabled but the stream or response does not actually contain tagged thinking. For streaming responses, reasoning-channel text is buffered during tagged streams and emitted only if no tagged thought is found, avoiding duplicate thought output when the provider sends both channels across different chunks.

Why it's needed

Issue #6007 reports that GLM-5.2 can return reasoning inline in content when the request uses a large max_tokens value such as 131072. In that response shape, reasoning_content is empty and the visible assistant output can include internal reasoning followed by a stray </think> tag before the final answer.

Qwen Code already has a tagged-thinking parser, but the DashScope GLM path was not opting into it for GLM requests. The resulting call chain is OpenAI-compatible GLM response -> OpenAI response converter -> normal assistant text parts -> stream-json / TUI output, which lets raw thinking tags reach user-visible output instead of the thought channel.

Reviewer Test Plan

How to verify

Reviewers can verify the unit behavior with a GLM-style response whose content is <think>reasoning</think>final: the converter should emit one thought part for reasoning and one normal text part for final.

The added coverage also verifies:

  • DashScope parsing options are based on the effective request model, including request-level GLM and non-GLM overrides.
  • Non-streaming reasoning_content is preserved when no thinking tags are present.
  • Non-streaming duplicate reasoning_content is suppressed when tagged thinking has already been parsed from content.
  • Streaming duplicate reasoning_content is suppressed when tagged thinking appears in the same chunk.
  • Streaming duplicate reasoning_content is suppressed when the reasoning channel arrives first and tagged content arrives in a later chunk.
  • Streaming reasoning-channel text is still flushed on finish when tagged parsing is enabled but no tagged thought appears in content.

Evidence (Before & After)

Before: the response shape reported in #6007 would pass inline <think>...</think> content through the DashScope GLM path as normal visible assistant text. Request-level model overrides could also choose a GLM model while the parsing option stayed tied to the configured default model.

After: DashScope OpenAI-compatible requests whose effective model is glm-* enable tagged thinking parsing, so inline tagged reasoning is converted to thought content while the final answer remains normal text. Responses that provide reasoning through reasoning_content still preserve that separate reasoning channel when no tagged block is present, including streaming responses.

Targeted validation:

npm exec vitest run packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts packages/core/src/core/openaiContentGenerator/converter.test.ts packages/core/src/core/openaiContentGenerator/pipeline.test.ts -- --coverage=false

Result: 3 test files passed, 297 tests passed.

Typecheck validation:

npm run typecheck --workspace=packages/core

Result: passed.

Build validation from the earlier PR revision:

npm run build --workspace=packages/core

Result: passed.

npm run build
npm run bundle

Result: passed locally on Windows. The build completed with existing warnings about VS Code companion lint style, stale Browserslist data, and large Vite chunks; no build errors.

Additional validation:

git diff --check

Result: passed.

Live API smoke check from the earlier PR revision:

  • DashScope standard OpenAI-compatible endpoint, https://dashscope.aliyuncs.com/compatible-mode/v1, model glm-5.2, non-streaming, max_tokens=131072: API returned final content separately from reasoning_content; normal content contained no <think> / </think> tags.
  • DashScope standard OpenAI-compatible endpoint, same model and prompt, streaming, max_tokens=131072: raw streamed deltas reproduced the issue shape. Aggregated visible content contained one stray </think> tag, no <think> tag, and empty reasoning_content / reasoning deltas.
  • Qwen Code stream-json on this PR branch against the same DashScope GLM-5.2 endpoint, model, and prompt: process exited 0; 12 JSON records were emitted; output contained the expected final marker and contained no <think> or </think> tags.
  • Z.AI direct GLM-5.2, https://api.z.ai/api/paas/v4, max_tokens=131072: API returned clean final content and separate reasoning_content; no <think> / </think> tags in normal content.
  • Z.AI direct GLM-5.2, https://api.z.ai/api/paas/v4, max_tokens=8000: API returned clean final content and separate reasoning_content; no <think> / </think> tags in normal content.
  • Z.AI direct GLM-5.2, https://api.z.ai/api/coding/paas/v4, max_tokens=131072: API returned clean final content and separate reasoning_content; no <think> / </think> tags in normal content.
  • Z.AI direct GLM-5.2, https://api.z.ai/api/coding/paas/v4, max_tokens=8000: API returned clean final content and separate reasoning_content; no <think> / </think> tags in normal content.
  • Qwen Code stream-json against Z.AI direct GLM-5.2 with max_tokens=131072: assistant text was LEAK_CHECK_FINAL: prize is in B. with no <think> / </think> tags.

The DashScope live streaming smoke check reproduces the reporter's provider path and response shape, and the Qwen Code stream-json smoke check verifies that this PR prevents those raw thinking tags from reaching normal assistant output for that path. The Z.AI direct checks are kept only as additional non-regression context because they use a different provider endpoint and did not reproduce the leak shape.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ✅ tested via unit tests, typecheck, build, bundle, DashScope live smoke check, and Z.AI live smoke check
🐧 Linux ✅ GitHub Actions Test (ubuntu-latest, Node 22.x) passed on the earlier PR revision; latest push pending CI

Environment (optional)

Local validation ran on Windows with the repository's current PATH node (v24.15.0). GitHub Actions validated the earlier PR revision on Node 22. Live DashScope validation used the standard OpenAI-compatible endpoint with a temporary API key provided for this PR verification; the key is not stored in this branch or PR text.

Risk & Scope

  • Main risk or tradeoff: Low to medium; this changes response parsing option selection for DashScope OpenAI-compatible requests whose effective model is glm-*, and it adjusts converter fallback behavior so separate reasoning content is preserved only when tagged parsing does not produce thought content.
  • Reviewer-requested risk coverage: request-level model overrides now select parsing options from the effective request model; streaming dual-channel responses now avoid duplicate thought output across chunks; reasoning-channel cumulative-delta state is not advanced after tagged content has already produced a thought.
  • Sibling surfaces: MiniMax keeps its existing always-on tagged-thinking parsing behavior. Non-GLM DashScope effective models keep tagged parsing disabled. Non-tagged providers continue to stream reasoning_content immediately as before.
  • Not validated / out of scope: The DashScope live check is a focused smoke test, not exhaustive coverage for every GLM-5.2 prompt or endpoint variant. This PR does not change token-limit selection, request construction, provider routing, or non-DashScope GLM endpoint behavior.
  • Breaking changes / migration notes: None expected.

Linked Issues

Fixes #6007.

中文说明

What this PR does

这个 PR 会在 DashScope OpenAI-compatible 路径中,为有效请求模型是 glm-* 的请求启用 tagged thinking 解析,因此当普通 content 字段返回 <think>...</think> 内容时,这段内容会被转换为 thought part,而不是作为普通 assistant 输出展示。

解析决策现在跟随有效请求模型(request.model || contentGeneratorConfig.model)。这可以覆盖两种情况:默认配置不是 GLM、但单次请求覆盖到 GLM;以及默认配置是 GLM、但单次请求覆盖到非 GLM。

同时,当 tagged parsing 已启用但 stream 或 response 里没有真正出现 tagged thinking 时,这个 PR 仍然保留 provider 返回的 reasoning_content / reasoning。对于流式响应,reasoning channel 会先暂存,并且只有在整个 tagged stream 没有解析出 thought 时才输出,从而避免 provider 在不同 chunk 里同时发送 reasoning channel 和 tagged content 时产生重复 thought。

Why it's needed

Issue #6007 报告了 GLM-5.2 在请求使用较大 max_tokens 值(例如 131072)时,可能会把 reasoning 内联返回在 content 中。在这种响应形态下,reasoning_content 为空,可见 assistant 输出里可能包含内部 reasoning,并在最终答案前出现一个游离的 </think> 标签。

Qwen Code 已经有 tagged-thinking parser,但 DashScope GLM 路径此前没有为 GLM 请求启用它。实际链路是 OpenAI-compatible GLM response -> OpenAI response converter -> normal assistant text parts -> stream-json / TUI output,这会让原始 thinking 标签进入用户可见输出,而不是 thought 通道。

Reviewer Test Plan

How to verify

Reviewer 可以用一个 GLM 风格响应验证单元行为:当 content<think>reasoning</think>final 时,converter 应该为 reasoning 输出一个 thought part,并为 final 输出一个普通 text part。

新增覆盖还验证了:

  • DashScope parsing options 会基于有效请求模型判断,包括 request-level GLM 和非 GLM override。
  • 非流式响应中,没有 thinking tag 时会保留独立的 reasoning_content
  • 非流式响应中,当 content 已经解析出 tagged thinking 时,不会重复输出 reasoning_content
  • 流式响应中,同一个 chunk 同时包含 tagged thinking content 和 reasoning channel 时,会抑制重复 reasoning_content
  • 流式响应中,reasoning channel 先到、tagged content 后到时,也会抑制跨 chunk 的重复 reasoning_content
  • 流式响应中,如果 tagged parsing 已启用但 content 中没有 tagged thought,finish 时仍会输出暂存的 reasoning channel。

Evidence (Before & After)

Before:#6007 报告的响应形态会让内联 <think>...</think> 内容在 DashScope GLM 路径中作为普通可见 assistant 文本透出。request-level model override 也可能实际请求 GLM,但 parsing option 仍绑定在默认配置 model 上。

After:DashScope OpenAI-compatible 请求只要有效模型是 glm-*,就会启用 tagged thinking 解析,因此内联 tagged reasoning 会转换为 thought content,最终答案仍保留为普通文本。对于通过 reasoning_content 提供 reasoning 的响应,如果没有 tagged block,也仍然会保留这个独立 reasoning 通道,包括流式响应。

目标验证:

npm exec vitest run packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts packages/core/src/core/openaiContentGenerator/converter.test.ts packages/core/src/core/openaiContentGenerator/pipeline.test.ts -- --coverage=false

结果:3 个测试文件通过,297 个测试通过。

类型检查:

npm run typecheck --workspace=packages/core

结果:通过。

构建验证来自较早 PR revision:

npm run build --workspace=packages/core

结果:通过。

npm run build
npm run bundle

结果:在 Windows 本地通过。构建期间出现了既有的 VS Code companion lint style、Browserslist 数据较旧、Vite chunk 较大等 warning;没有 build error。

额外验证:

git diff --check

结果:通过。

Live API smoke check 来自较早 PR revision:

  • DashScope standard OpenAI-compatible endpoint,https://dashscope.aliyuncs.com/compatible-mode/v1,模型 glm-5.2,非流式,max_tokens=131072:API 返回了独立的 final contentreasoning_content;普通 content 中没有 <think> / </think> 标签。
  • DashScope standard OpenAI-compatible endpoint,同一模型和 prompt,流式,max_tokens=131072:原始 streamed deltas 复现了 issue 形态。聚合后的可见 content 中出现 1 个游离 </think> 标签,没有 <think> 标签,并且 reasoning_content / reasoning delta 为空。
  • 这个 PR 分支上的 Qwen Code stream-json 使用同一个 DashScope GLM-5.2 endpoint、模型和 prompt 测试:进程退出码为 0,输出 12 条 JSON record;输出包含预期 final marker,并且不包含 <think></think> 标签。
  • Z.AI direct GLM-5.2,https://api.z.ai/api/paas/v4max_tokens=131072:API 返回了干净的 final content 和独立的 reasoning_content;普通 content 中没有 <think> / </think> 标签。
  • Z.AI direct GLM-5.2,https://api.z.ai/api/paas/v4max_tokens=8000:API 返回了干净的 final content 和独立的 reasoning_content;普通 content 中没有 <think> / </think> 标签。
  • Z.AI direct GLM-5.2,https://api.z.ai/api/coding/paas/v4max_tokens=131072:API 返回了干净的 final content 和独立的 reasoning_content;普通 content 中没有 <think> / </think> 标签。
  • Z.AI direct GLM-5.2,https://api.z.ai/api/coding/paas/v4max_tokens=8000:API 返回了干净的 final content 和独立的 reasoning_content;普通 content 中没有 <think> / </think> 标签。
  • Qwen Code stream-json 使用 Z.AI direct GLM-5.2、max_tokens=131072 测试:assistant text 为 LEAK_CHECK_FINAL: prize is in B.,没有 <think> / </think> 标签。

DashScope live streaming smoke check 复现了 reporter 提到的 provider 路径和响应形态;Qwen Code stream-json smoke check 则验证了这个 PR 会阻止这些原始 thinking tag 进入普通 assistant 输出。Z.AI direct 检查只作为额外的非回归背景保留,因为它使用的是另一个 provider endpoint,并且没有复现泄漏形态。

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ✅ tested via unit tests, typecheck, build, bundle, DashScope live smoke check, and Z.AI live smoke check
🐧 Linux ✅ GitHub Actions Test (ubuntu-latest, Node 22.x) 在较早 PR revision 已通过;latest push 等待 CI

Environment (optional)

本地验证在 Windows 上运行,当前 PATH 中的 node 是 v24.15.0。GitHub Actions 已在较早 PR revision 上通过 Node 22 验证。DashScope live 验证使用的是 standard OpenAI-compatible endpoint 和为本 PR 验证临时提供的 API key;这个 key 没有写入分支或 PR 文本。

Risk & Scope

  • Main risk or tradeoff:低到中等;这个 PR 会改变 DashScope OpenAI-compatible 路径下有效模型为 glm-* 的请求的响应解析选项,并调整 converter fallback 行为,使独立 reasoning content 只在 tagged parsing 没有产出 thought content 时保留。
  • Reviewer-requested risk coverage:request-level model override 现在会根据有效请求模型选择 parsing options;streaming dual-channel response 现在会避免跨 chunk 重复 thought 输出;tagged content 已经产出 thought 后,不会继续推进 reasoning-channel cumulative-delta state。
  • Sibling surfaces:MiniMax 保持现有 always-on tagged-thinking parsing 行为。非 GLM 的 DashScope 有效模型仍不开启 tagged parsing。非 tagged providers 继续像之前一样即时输出 streaming reasoning_content
  • Not validated / out of scope:DashScope live check 是聚焦的 smoke test,不是对所有 GLM-5.2 prompt 或 endpoint 变体的穷尽覆盖。这个 PR 不改变 token-limit 选择、request construction、provider routing 或非 DashScope GLM endpoint 行为。
  • Breaking changes / migration notes:预计没有。

Linked Issues

Fixes #6007.

@VectorPeak

VectorPeak commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Temporary DashScope validation key template for reviewer reproduction:

For convenience, I can provide a temporary, limited-scope DashScope / ModelStudio test key during this PR validation window. I will revoke it after validation. To avoid leaving credentials in public PR history, please request the temporary key through a private channel if needed.

  • Endpoint: https://dashscope.aliyuncs.com/compatible-mode/v1
  • Model: glm-5.2
  • Temporary API key: <PASTE_TEMP_DASHSCOPE_KEY_HERE>

Reproduction command shape:

$env:OPENAI_API_KEY='<PASTE_TEMP_DASHSCOPE_KEY_HERE>'
$env:OPENAI_BASE_URL='https://dashscope.aliyuncs.com/compatible-mode/v1'
$env:OPENAI_MODEL='glm-5.2'

npm run dev -- --auth-type openai --model glm-5.2 --output-format stream-json --approval-mode=yolo --sandbox=false --chat-recording=false --include-partial-messages '<leak-check prompt>'

Observed result on this PR branch:

  • Raw DashScope streaming reproduced the issue shape: visible streamed content contained a stray </think> and empty reasoning_content / reasoning deltas.
  • Qwen Code stream-json on this PR branch exited 0, emitted the expected final marker, and did not contain <think> or </think> in normal assistant output.

@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/28426519681)._

return {};
}

getResponseParsingOptions(): OpenAIResponseParsingOptions {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This gates tagged-thinking parsing on contentGeneratorConfig.model, but the pipeline sends request.model || contentGeneratorConfig.model as the effective model. If a DashScope caller overrides a non-GLM config to glm-*, the request still goes to GLM while parsing stays disabled, so inline <think> content can leak as visible assistant text. The reverse case also enables GLM parsing for non-GLM request overrides. Please make the parsing option depend on the effective request model, for example by passing that model into this hook from createRequestContext().

— GPT-5 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for catching this. Addressed in 5377e9d. createRequestContext() now passes the effective model (request.model || contentGeneratorConfig.model) into getResponseParsingOptions(), and the DashScope provider uses that model to decide whether glm-* tagged-thinking parsing should be enabled. I also added provider tests for both override directions and a pipeline test that verifies the provider hook is called with the effective request model.

cumulativeMode: false,
}),
);
if (normalizedReasoningText && !hasThoughtPart(contentParts)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This duplicate guard only checks thought parts parsed from the current content chunk. In streaming, the provider reasoning channel and tagged content can arrive in different chunks: a reasoning_content: "secret" chunk can be emitted first, then a later <think>secret</think> content chunk emits the same thought again because contentParts from the earlier chunk is no longer available. Track whether tagged content has produced a thought across the whole stream, or buffer reasoning-channel output for tagged streams until the converter knows no tagged thought was emitted.

— GPT-5 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you, this was a real gap. Addressed in 5377e9d. For tagged-thinking streams, the converter now tracks whether content parsing has emitted a thought across the whole stream and buffers reasoning-channel text until stream finish. If tagged content emits a thought in any chunk, the buffered reasoning is discarded so the same reasoning is not emitted twice. If the stream finishes without any tagged thought, the buffered reasoning channel is flushed so GLM responses that only use reasoning_content still work.

}

if (reasoningText) {
const normalizedReasoningText = normalizeStreamingTextDelta(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] normalizeStreamingTextDelta is called unconditionally here, mutating requestContext.reasoningDeltaState (updating emittedText, emittedLength, cumulativeMode) even when the result is subsequently dropped by the !hasThoughtPart(contentParts) guard on line 1268. For cumulative-delta providers, this corrupts the tracking state: the next chunk's normalization computes a suffix against emittedText that includes bytes the user never saw, potentially producing duplicate or garbled reasoning fragments.

The old code on main avoided this entirely — the if (!requestContext.responseParsingOptions?.taggedThinkingTags) gate prevented normalizeStreamingTextDelta from being called at all when tagged thinking was enabled.

Consider moving the normalizeStreamingTextDelta call inside the hasThoughtPart guard so state is only mutated when the result will actually be emitted:

Suggested change
const normalizedReasoningText = normalizeStreamingTextDelta(
if (reasoningText && !hasThoughtPart(contentParts)) {
const normalizedReasoningText = normalizeStreamingTextDelta(
reasoningText,
(requestContext.reasoningDeltaState ??= {
emittedText: '',
emittedLength: 0,
cumulativeMode: false,
}),
);
if (normalizedReasoningText) {
parts.push({ text: normalizedReasoningText, thought: true });
}
}

Note: the per-chunk hasThoughtPart(contentParts) check itself is also weaker than the non-streaming counterpart — it only inspects the current chunk's parts, not accumulated stream state. If a model sends both channels concurrently, reasoning_content may leak on chunks where contentParts lacks a thought part (e.g., after </think> closes). A sticky flag on requestContext (e.g., hasEmittedThoughtFromContent) would make the suppression consistent across chunks.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the detailed suggestion. Addressed in 5377e9d. The updated logic avoids advancing reasoningDeltaState after tagged content has already produced a thought, and for tagged streams it buffers reasoning-channel text rather than normalizing-and-dropping it behind a per-chunk guard. That should avoid the cumulative-delta state corruption you pointed out while also handling cross-chunk duplicate suppression.

{ text: 'tagged reasoning', thought: true },
{ text: 'final answer' },
]);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The three new tests cover the non-streaming path (convertOpenAIResponseToGemini) well, but the streaming path (convertOpenAIChunkToGemini) received an equally significant refactor — the hasThoughtPart deduplication, the reordered push logic, and the contentParts local variable — with zero new streaming tests.

The existing streaming test at line ~3758 ("should handle a single chunk delta with both reasoning_content and content simultaneously") does not use withTaggedThinkingOptions(), so the new deduplication branch is untested in streaming.

Consider adding at least two streaming tests using withTaggedThinkingStreamParser():

  1. A chunk with both content: '<think>...</think>answer' and reasoning_content: '...', verifying reasoning_content is suppressed.
  2. Sequential chunks where chunk 1 has tagged thinking in content and chunk 2 has only reasoning_content, verifying the cross-chunk behavior.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, the streaming path needed direct coverage. Addressed in 5377e9d with new convertOpenAIChunkToGemini tests for: same-chunk tagged content plus reasoning_content; reasoning-channel text arriving before a later tagged-thinking content chunk; and the fallback case where tagged parsing is enabled but no tagged thought appears, so buffered reasoning_content is flushed at finish. Targeted validation passed: npm exec vitest run packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts packages/core/src/core/openaiContentGenerator/converter.test.ts packages/core/src/core/openaiContentGenerator/pipeline.test.ts -- --coverage=false (3 files, 297 tests).

@VectorPeak
VectorPeak requested a review from wenshao June 30, 2026 14:03
DragonnZhang
DragonnZhang previously approved these changes Jun 30, 2026

@DragonnZhang DragonnZhang 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 the PR-specific changes across converter.ts, dashscope.ts, pipeline.ts, types.ts, and provider/types.ts along with all associated tests.

The tagged-thinking parsing integration for DashScope GLM responses is well-implemented:

  • Effective model routing: getResponseParsingOptions now receives the effective request model (request.model || config.model), correctly handling both override directions (non-GLM config to GLM request, and GLM config to non-GLM request).
  • Non-streaming deduplication: reasoning_content is preserved unless content parsing already produced a thought part — clean and correct.
  • Streaming cross-chunk deduplication: The hasTaggedThinkingThought sticky flag on RequestContext correctly suppresses duplicate reasoning across the entire stream, not just per-chunk. The buffered reasoning flush on finish_reason when no tagged thought was found preserves the reasoning channel for responses that use only reasoning_content.
  • Reasoning delta state: normalizeStreamingTextDelta is no longer called when the result would be dropped, avoiding cumulative-delta state corruption.
  • Test coverage: All six new test cases cover the key scenarios: inline tagged thinking, reasoning preservation, non-streaming dedup, same-chunk streaming dedup, cross-chunk streaming dedup, and reasoning flush on finish.

All previously raised review comments (effective model, cross-chunk dedup, delta state mutation, streaming tests) have been addressed in commit 5377e9d.

requestContext.pendingReasoningText
) {
parts.push({ text: requestContext.pendingReasoningText, thought: true });
requestContext.pendingReasoningText = undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] When taggedThinkingTags is enabled but content has no <think> tags, this flush emits the buffered reasoning as a thought part on the final chunk. By that point, text content from earlier chunks has already been yielded downstream. The result is that the "thought" appears after the answer text in the stream — semantically backwards.

For example, with a 2-chunk GLM stream: chunk 1 yields [{text: "final "}], chunk 2 (finish) yields [{text: "reasoning", thought: true}, {text: "answer"}]. The user sees "final " before the thought bubble.

Consider buffering content parts alongside pendingReasoningText when taggedThinkingTags is enabled and no tagged thinking has been seen yet, then emitting everything in correct order (thought first, then accumulated content) on the finish chunk. Alternatively, document this ordering tradeoff in a code comment.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing out the ordering problem. Addressed in 117a32b9b: when tagged-thinking parsing is enabled and provider reasoning_content has been buffered, visible content parts are now buffered behind it until the stream finishes or until tagged content actually emits a thought. That makes the fallback no-tag path emit reasoning before accumulated answer content on the finish chunk, while keeping the buffering narrow to streams that have already produced reasoning-channel text.

]);
});

it('should flush buffered reasoning_content when tagged streaming content has no thinking tags', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This flush test always includes a content field alongside reasoning_content in the first chunk. There is no test for multi-chunk reasoning_content with no content field at all, where pendingReasoningText accumulates across reasoning-only chunks before being flushed on finish_reason.

Consider adding a test with: chunk 1 = {reasoning_content: "step 1"} (no content, no finish), chunk 2 = {finish_reason: "stop"} (no content, no reasoning). Assert chunk 1 emits [] and chunk 2 emits [{text: "step 1", thought: true}].

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the exact reasoning-only coverage in 117a32b9b: first chunk has only reasoning_content: "step 1" and no content/finish, then the finish chunk has no content or reasoning. The test asserts the first chunk emits [] and the finish chunk emits [{ text: "step 1", thought: true }].

}
}

if (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] pendingReasoningText is only flushed when finish_reason is truthy. If a streaming response terminates abnormally (connection drop, timeout, client cancellation), the final chunk never arrives and all buffered reasoning is silently lost.

For providers like MiniMax (which unconditionally enables taggedThinkingTags), the old code dropped reasoning immediately while the new code buffers it with a promise of later delivery — a promise broken on abnormal termination. Consider documenting this assumption in the pendingReasoningText JSDoc so future maintainers know buffered reasoning is best-effort.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Documented this in 117a32b9b on the pendingReasoningText stream state: buffered reasoning is best-effort and may be lost if the stream ends abnormally before a finish chunk. I kept this as documentation rather than changing abnormal-termination behavior, because without a final chunk the converter has no safe point to decide whether tagged content would have appeared later.

DragonnZhang
DragonnZhang previously approved these changes Jun 30, 2026

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

Incremental review of 117a32b9b (preserve tagged reasoning stream order): LGTM.

The buffering logic correctly preserves thought-before-answer ordering for GLM responses that use reasoning_content without thinking tags. The three flush paths (on hasThoughtPart, on finish_reason, and on stream end) are all well-guarded. Test expectations properly updated and the new reasoning-only test covers the edge case.

No issues found.

@VectorPeak

Copy link
Copy Markdown
Contributor Author

Hi @wenshao and @DragonnZhang, thank you again for the careful review and the detailed follow-up suggestions.

I pushed 117a32b9b and then synced the branch with main; the current PR head is 9ae16efbdc9339f93df6b2890561eca82efd30e6.

The latest reviewer feedback has been addressed:

  • Buffered visible content behind pending reasoning_content for tagged-thinking streams until finish or until tagged content emits a thought, so the no-tag fallback path preserves thought-before-answer ordering.
  • Added the requested reasoning-only streaming test where a chunk has only reasoning_content and the finish chunk flushes it as a thought part.
  • Documented that buffered reasoning is best-effort if the stream ends abnormally before a finish chunk.

Validation is now green on the latest head:

  • Test (ubuntu-latest, Node 22.x): passed
  • Classify PR: passed
  • Local targeted validation before push: dashscope.test.ts, converter.test.ts, and pipeline.test.ts passed: 298 tests; npm run typecheck --workspace=packages/core passed; git diff --check passed.

When convenient, could you please take another look? I really appreciate your time and the thoughtful review.

parts.push({ text: requestContext.pendingReasoningText, thought: true });
requestContext.pendingReasoningText = undefined;
}
parts.push(...contentParts);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] pendingContentParts (buffered content from earlier chunks) is pushed after contentParts (current chunk's content). When the finish chunk carries new content text while pendingReasoningText is still set, this produces wrong output order.

Reproduction: chunk 1 {reasoning_content: "step 1", content: "hello "} buffers "hello " in pendingContentParts. Finish chunk {content: "world"} flushes pendingReasoningText as thought, then pushes [{text:"world"}], then [{text:"hello "}]. Output: [{thought}, {text:"world"}, {text:"hello "}] — should be [{thought}, {text:"hello "}, {text:"world"}].

Suggested change
parts.push(...contentParts);
if (choice.finish_reason && requestContext.pendingContentParts?.length) {
parts.push(...requestContext.pendingContentParts);
requestContext.pendingContentParts = undefined;
}
parts.push(...contentParts);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching this. Fixed in 029c0ced9: the finish path now flushes pendingContentParts before the current chunk's contentParts, so the buffered earlier visible text keeps its original order after the flushed thought.

I also added should flush buffered content before current content when reasoning flushes on finish, which covers the exact hello + world ordering case.

}

if (hasThoughtPart(contentParts)) {
requestContext.hasTaggedThinkingThought = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new streaming state machine (~65 lines, 5 conditional blocks, 3 new mutable fields) has zero debugLogger calls, while the rest of this file uses debugLogger extensively (11 calls elsewhere). When this state machine misbehaves in production, there are no breadcrumbs.

Consider adding debugLogger.debug calls at key state transitions: when hasTaggedThinkingThought is set, when pendingReasoningText is buffered or discarded, and when pendingContentParts is accumulated or flushed.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. Added debug breadcrumbs in 029c0ced9 for the main state transitions: tagged thought detection / buffered reasoning discard, buffering reasoning_content, buffering visible content behind pending reasoning, flushing buffered reasoning, and flushing buffered content.

The logs only include state and counts/lengths, not the actual reasoning text.

]);
});

it('should suppress reasoning_content when the same streaming chunk has tagged thinking content', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] No streaming test covers the sequence where tagged thinking content arrives first (setting hasTaggedThinkingThought), then reasoning_content arrives in a later chunk. The gating logic at converter.ts:1269 (!requestContext.hasTaggedThinkingThought) suppresses late reasoning, but this code path is untested.

Suggested test: chunk 1 {content: '<think>thinking</think>'}, chunk 2 {reasoning_content: 'late reasoning'}. Assert chunk 2 produces no thought part and the late reasoning_content is silently dropped.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 029c0ced9 as should suppress late reasoning_content after streaming tagged thinking content.

The test streams a tagged <think>...</think> content chunk first, then a later reasoning_content chunk, and asserts the late reasoning chunk produces no output parts.

]);
});

it('should flush reasoning-only chunks when tagged streaming content has no thinking tags', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] No test exercises the pendingContentParts drain path with actual visible content. The existing flush tests start with reasoning-only chunks (no content field), so pendingContentParts stays empty and the drain code path is never hit.

Suggested test: chunk 1 {reasoning_content: 'reasoning', content: 'early visible'} (content buffered in pendingContentParts), chunk 2 {content: '<think>tagged</think>final'} (triggers hasThoughtPart + drain). Assert the ordering of drained visible content relative to the thought part.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added coverage in 029c0ced9. The new should flush buffered content before current content when reasoning flushes on finish test exercises pendingContentParts with actual visible content (hello ) and verifies it is emitted before the finish chunk's current visible content (world).

The adjacent tagged-content test also continues to cover the path where a later tagged thought suppresses buffered reasoning and drains any pending content before the tagged content path proceeds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Follow-up: I also added cb2957143, which covers the exact tagged-content drain shape suggested here: chunk 1 has reasoning_content plus early visible , and chunk 2 has <think>tagged reasoning</think>final answer.

That test verifies the buffered visible content is drained before the later tagged thought and final text.

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

LGTM. The streaming state machine logic is correct across all traced scenarios, tests are comprehensive (297 passing), and the fix cleanly addresses the GLM tagged-thinking leak reported in the linked issue.

— qwen3.7-max via Qwen Code /review

@VectorPeak

Copy link
Copy Markdown
Contributor Author

Hi @wenshao and @DragonnZhang, thank you again for the careful follow-up review.

I pushed two follow-up commits addressing the latest feedback:

  • 029c0ced9 fixes the pendingContentParts finish-order issue, adds debug breadcrumbs for the tagged-thinking streaming state machine, and adds tests for late reasoning_content suppression plus finish-time buffered visible content ordering.
  • cb2957143 adds the exact tagged-content drain regression case suggested in the review: a chunk with reasoning_content plus visible content, followed by a later <think>...</think> content chunk.

Validation on the latest head cb29571437dfdd88816666704ced6de40b539266:

  • Local: npx prettier --check packages/core/src/core/openaiContentGenerator/converter.ts packages/core/src/core/openaiContentGenerator/converter.test.ts packages/core/src/core/openaiContentGenerator/types.ts passed.
  • Local: npm exec vitest run packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts packages/core/src/core/openaiContentGenerator/converter.test.ts packages/core/src/core/openaiContentGenerator/pipeline.test.ts -- --coverage=false passed: 301 tests.
  • Local: npm run typecheck --workspace=packages/core passed.
  • Local: git diff --check passed.
  • Remote CI: Classify PR passed and Test (ubuntu-latest, Node 22.x) passed. The macOS, Windows, and integration jobs were skipped by the repository workflow for this PR.

When convenient, could you please take another look? I really appreciate your time and the detailed review guidance.

@DragonnZhang

Copy link
Copy Markdown
Collaborator

E2E Tmux Test Report — PR #6033

Tested on: Linux (Ubuntu, x86_64) | Branch: codex/glm-tagged-thinking at cb29571 | Build: Pass

Test Results

Test Description Result
1 Converter unit tests (tagged thinking) Pass
2 Core typecheck (tsc --noEmit) Pass

Details

Test 1 — Converter unit tests:

 RUN  v3.2.4 /root/qwen-code-review-pr
 PASS  @qwen-code/qwen-code-core  src/core/openaiContentGenerator/converter.test.ts (130 tests) 583ms

 Test Files  1 passed (1)
      Tests  130 passed (130)
   Duration  60.40s

All 130 converter tests pass, including the new tagged-thinking parse tests introduced by this PR.

Test 2 — Core typecheck:

> @qwen-code/qwen-code-core@0.19.3 typecheck
> tsc --noEmit

tsc --noEmit completed with zero errors. The new types and pipeline changes compile cleanly.

Verdict

PASS — All tests green. The tagged thinking parser for GLM responses is correctly implemented and type-safe.

中文测试报告

E2E Tmux 测试报告 — PR #6033

测试环境: Linux (Ubuntu, x86_64) | 分支: codex/glm-tagged-thinking @ cb29571 | 构建: 通过

测试结果

测试 描述 结果
1 Converter 单元测试(标记思考) 通过
2 Core 类型检查 (tsc --noEmit) 通过

详情

测试 1 — Converter 单元测试:
全部 130 个 converter 测试通过(583ms),包括本 PR 新增的 GLM 标记思考解析测试。

测试 2 — Core 类型检查:
tsc --noEmit 零错误完成,新增的类型和 pipeline 变更编译正常。

结论

通过 — 所有测试均为绿色。GLM 响应的标记思考解析器实现正确且类型安全。

@wenshao

wenshao commented Jun 30, 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, real bug — GLM-5.2 leaking <think> tags into visible assistant output when DashScope streams reasoning inline. Fixes #6007, squarely in the content-generation path that qwen-code needs to get right. No direction concerns.

On approach: The scope feels right for the problem. The core changes are small (provider gets a getResponseParsingOptions(model?) override, converter gets a hasThoughtPart guard, streaming path gets reasoning buffering). The 420 lines of tests are warranted by the edge-case combinatorics of dual-channel reasoning dedup across streaming/non-streaming. The three new RequestContext fields (hasTaggedThinkingThought, pendingReasoningText, pendingContentParts) are the minimum state needed for the ordering guarantees. Nothing feels like scope creep.

One thing worth noting for the code review: the streaming logic now has several interleaved conditionals (buffer reasoning → buffer content → flush on tagged thought → flush on finish). It's correct but it takes concentration to follow. That's a readability trade-off, not a blocker.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:这是一个明确的真实 bug — GLM-5.2 在 DashScope 流式输出时将 <think> 标签泄漏到可见 assistant 输出中。修复 #6007,属于 qwen-code 需要正确处理的内容生成路径。方向没有问题。

方案:范围与问题匹配。核心改动很小(provider 增加 getResponseParsingOptions(model?) 覆盖,converter 增加 hasThoughtPart 守卫,流式路径增加 reasoning 缓冲)。420 行测试在双通道 reasoning 去重的边界组合下是合理的。三个新的 RequestContext 字段是保证排序所需的最小状态。没有范围蔓延。

值得注意的是,流式逻辑现在有若干交织的条件分支(缓冲 reasoning → 缓冲 content → 遇到 tagged thought 时刷新 → finish 时刷新)。逻辑正确,但需要集中注意力才能跟上。这是可读性上的取舍,不是阻塞问题。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The implementation is solid. I traced through the streaming logic carefully — the buffering/flushing for dual-channel reasoning dedup is correct across all interleaving scenarios (reasoning-before-tags, tags-before-reasoning, reasoning-only, mixed chunks). The three new RequestContext fields are the minimum state needed, and the hasThoughtPart guard in the non-streaming path is clean.

The getResponseParsingOptions(model?) interface extension is minimal — just an optional parameter that DashScope overrides. No other providers are affected.

No correctness bugs, no security issues, no regressions spotted. The debug logging is helpful for future troubleshooting without being noisy.

One observation (not a blocker): the streaming section of convertOpenAIChunkToGemini now has ~80 lines of interleaved conditionals for the reasoning/content/thought ordering logic. It's correct, but it's the kind of code where a future contributor could easily introduce a bug by modifying one branch without understanding the others. If this area grows further, extracting a small state machine would help — but at the current size it's manageable.

Test Results

Unit Tests (PR branch)

$ npx vitest run packages/core/src/core/openaiContentGenerator/converter.test.ts \
    packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts \
    packages/core/src/core/openaiContentGenerator/pipeline.test.ts --coverage=false

 RUN  v3.2.4

 ✓  @qwen-code/qwen-code-core  src/core/openaiContentGenerator/provider/dashscope.test.ts (75 tests) 32ms
 ✓  @qwen-code/qwen-code-core  src/core/openaiContentGenerator/pipeline.test.ts (81 tests) 86ms
 ✓  @qwen-code/qwen-code-core  src/core/openaiContentGenerator/converter.test.ts (145 tests) 43ms

 Test Files  3 passed (3)
      Tests  301 passed (301)
   Duration  3.67s

Typecheck

$ npm run typecheck --workspace=packages/core

> @qwen-code/qwen-code-core@0.19.3 typecheck
> tsc --noEmit

(no errors)

Real-Scenario Testing

This is a provider-level response parsing fix for DashScope GLM-5.2 — reproducing the original bug requires a live DashScope API key with GLM-5.2 access and the specific max_tokens=131072 configuration that triggers inline thinking tags. I don't have DashScope GLM credentials in this CI environment, so I can't run the before/after stream-json smoke test.

The PR author did run live DashScope smoke tests (documented in the PR body) confirming:

  • DashScope streaming GLM-5.2 with max_tokens=131072 reproduces the </think> tag leak
  • stream-json on this PR branch produces clean output with no thinking tags

The unit test coverage is comprehensive — 12 new test cases covering all the dual-channel dedup scenarios (same-chunk, cross-chunk, reasoning-first, content-first, no-tagged-thought fallback). This gives high confidence the logic is correct even without live API access.

中文说明

代码审查

实现质量很好。我仔细追踪了流式逻辑 — reasoning/content/thought 的缓冲/刷新在所有交错场景下(reasoning 先于 tags、tags 先于 reasoning、只有 reasoning、混合 chunk)都是正确的。三个新的 RequestContext 字段是所需的最小状态,非流式路径中的 hasThoughtPart 守卫也很简洁。

getResponseParsingOptions(model?) 的接口扩展是最小化的 — 只是一个可选参数,DashScope 对其进行了覆盖。其他 provider 不受影响。

没有发现正确性 bug、安全问题或回归。debug 日志对未来排查有帮助,且不会产生噪音。

一个观察(不是阻塞问题): convertOpenAIChunkToGemini 的流式部分现在有约 80 行交织的条件判断来处理 reasoning/content/thought 的排序逻辑。逻辑正确,但如果未来这个区域继续增长,提取一个小型状态机会有帮助 — 但当前规模是可以管理的。

测试结果

单元测试(PR 分支)

3 个测试文件全部通过,301 个测试用例全部通过。

类型检查

npm run typecheck --workspace=packages/core 通过,没有错误。

真实场景测试

这是一个 DashScope GLM-5.2 的 provider 级别响应解析修复 — 复现原始 bug 需要 DashScope API key、GLM-5.2 模型和特定的 max_tokens=131072 配置。CI 环境中没有 DashScope GLM 凭证,因此无法运行 before/after stream-json 冒烟测试。

PR 作者已运行了 live DashScope 冒烟测试(记录在 PR 正文中),确认了 bug 复现和修复效果。单元测试覆盖了 12 个新测试用例,涵盖所有双通道去重场景,即使没有 live API 也能高度确信逻辑正确。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, focused bug fix for a real user-reported issue (#6007). The implementation matches what I would have done independently — enable tagged thinking parsing for GLM models in the DashScope provider, then handle the dual-channel reasoning dedup (tagged content vs reasoning_content) in both streaming and non-streaming converter paths.

The streaming logic is the most complex part of this PR, but it's the minimum complexity the problem demands. The provider can send reasoning via two channels in unpredictable chunk orderings, and the code correctly handles every interleaving: buffer reasoning behind potential tagged content, flush reasoning as thought on finish if no tags appeared, drop reasoning if tags did appear, and always preserve thought-before-answer ordering. The 12 new test cases validate each of these paths.

Nothing in the diff is extraneous. The interface change (getResponseParsingOptions gets an optional model parameter) is backward-compatible. The three new RequestContext fields are per-stream state that gets initialized lazily and cleaned up on finish. No new abstractions, no speculative features.

The only caveat is that I couldn't run the live DashScope smoke test in CI (no GLM API credentials), but the author's documented live tests plus the comprehensive unit coverage give high confidence.

Approving. ✅

中文说明

这是一个干净、聚焦的 bug 修复,解决了用户报告的真实问题(#6007)。实现方式与我独立设想的方案一致 — 在 DashScope provider 中为 GLM 模型启用 tagged thinking 解析,然后在流式和非流式 converter 路径中处理双通道 reasoning 去重(tagged content vs reasoning_content)。

流式逻辑是这个 PR 中最复杂的部分,但这是问题本身所需的最小复杂度。provider 可能以不可预测的 chunk 顺序通过两个通道发送 reasoning,代码正确处理了所有交错情况:在潜在 tagged content 之前缓冲 reasoning,如果最终没有出现 tags 则将 reasoning 作为 thought 刷新,如果出现了 tags 则丢弃 reasoning,并始终保持 thought 在 answer 之前的顺序。12 个新测试用例验证了每条路径。

diff 中没有任何多余内容。接口变更(getResponseParsingOptions 增加可选 model 参数)向后兼容。三个新的 RequestContext 字段是按需惰性初始化、结束时清理的 per-stream 状态。没有新抽象,没有投机性功能。

唯一的限制是我无法在 CI 中运行 live DashScope 冒烟测试(没有 GLM API 凭证),但作者的记录测试加上全面的单元覆盖提供了高度信心。

批准 ✅

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 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — PR #6033

Maintainer local build & real-test verification. Isolated worktree at PR head c99d28536, run on macOS. Unit/integration tests run with Vitest aliased to src (i.e. the real, unmodified PR code, no bundling).

TL;DR

The implementation is high quality and well-tested — all tests pass, every key behaviour is load-bearing under mutation, and Linux CI is green. However, real-test verification indicates the change does not actually fix the shape reported in #6007. Issue #6007's leaked output is a stray </think> with no opening <think>, and the tagged-thinking parser only enters thought-mode on an opening tag — so for the documented bytes the reasoning still leaks as visible text. Please re-validate the "Fixes #6007" claim before merging (details + reproducer below).

1. What was verified — all green ✅

Check Result
PR's 3 touched test files (converter / dashscope / pipeline) 301 passed
Full openaiContentGenerator/ suite (regression sweep) 518 passed, 14 files
Mutation testing — 6 key behaviours neutered 6/6 FAIL as expected (tests are load-bearing)
Test (ubuntu-latest, Node 22.x) CI on PR head pass, 24m12s (this is the only leg that runs typecheck + lint + full suite)
Non-GLM / non-tagged providers no regression — reasoning_content preserved exactly as before
Well-formed <think>…</think> (streaming + non-streaming) converts correctly to thought + text

Mutation detail (each mutation reverts one behaviour; the relevant test then fails, proving coverage is real):

M1  pipeline passes effectiveModel to getResponseParsingOptions      -> FAIL ✓
M2  dashscope enables taggedThinkingTags for GLM models              -> FAIL ✓
M3  non-stream: suppress reasoning_content when content has a thought -> FAIL ✓
M4  stream: hasTaggedThinkingThought suppresses duplicate reasoning   -> FAIL ✓
M5  stream: flush buffered reasoning on finish (no tagged thought)    -> FAIL ✓
M6  stream: flush buffered visible content before tagged content      -> FAIL ✓

The streaming state machine (buffering / dedup / thought-before-answer ordering across chunks) was traced by hand against every new test and is internally correct.

2. Critical finding — the fix does not address #6007's actual shape ❌

Issue #6007's reported leak (its own stream-json evidence):

The user is asking a simple logic puzzle. … The answer is straightforward.</think>LEAK_CHECK_FINAL: 奖品在 B

This is a stray </think> closing tag with no opening <think> (the opening tag is prefilled by GLM's chat template and never appears in the API response). This PR's own raw-API evidence confirms it: "Aggregated visible content contained one stray </think> tag, no <think> tag, and empty reasoning_content/reasoning deltas."

The parser treats a stray </think> as ordinary text — by design. taggedThinkingParser.test.ts asserts exactly this:

it('should treat </think> as normal text in text mode (no opening tag)', () => {
  expect(parser.parse('some </think> text', true)).toEqual([{ text: 'some </think> text' }]);
});

In text mode the parser only scans for opening tags; a </think> never matches, so it stays visible text and mode never flips to thought.

Empirical proof — I fed issue #6007's exact bytes through the real converter at PR head (non-streaming, single-chunk streaming, and realistic multi-chunk streaming). In all three the result was 0 thought parts and the reasoning + </think> remained in visible text:

MULTICHUNK thoughts: []
MULTICHUNK visible: "The user is asking a simple logic puzzle. … straightforward.</think>LEAK_CHECK_FINAL: 奖品在 B"
MULTICHUNK visible contains </think>? true
MULTICHUNK visible contains reasoning leak? true

For contrast, a well-formed <think>reasoning</think>final converts correctly — but that is not the shape #6007 produces. And if an opening tag is implicitly supplied (<think> + the leaked content), the parser does produce [{thought}, {final}] — i.e. handling the prefilled/implicit-open case is what an actual fix needs.

Note on the PR's "after" smoke check: the raw-API call and the qwen-code stream-json call are two separate live calls, and the leak is intermittent / max_tokens-dependent. The qwen-code call returning "no tags" most likely means that call didn't reproduce the leak — it does not demonstrate the fix neutralising a leaking response. A deterministic replay of the captured leaking bytes (above) shows the leak persists.

No downstream sanitizer masks this — I grepped the output path; the converter is the only place tags are handled, and responseParsingOptions feeds both the streaming and non-streaming converter calls.

3. Recommendation

The changes are genuinely useful as hardening (correct tagged-thinking for well-formed content + cleaner reasoning_content dedup/ordering) and are safe to land on that basis. But before merging as the fix for #6007, please either:

  1. Re-validate with a deterministic GLM-5.2 repro at max_tokens=131072 — replay a captured leaking response (stray </think>) through qwen --output-format stream-json and confirm the tag is gone (not just a fresh live call that may not leak); or
  2. Extend the parser to handle the prefilled/implicit-open case — when a </think>/</thinking> appears in text mode with no preceding open tag, treat the preceding content as thought; or
  3. Cap max_tokens for GLM so the unsafe request shape is avoided (the alternative the issue itself suggests).

Minimal reproducer (drop into packages/core/src/core/openaiContentGenerator/, run with vitest):

import { OpenAIContentConverter } from './converter.js';
const LEAK = 'reasoning here.</think>LEAK_CHECK_FINAL: prize is in B';
const r = OpenAIContentConverter.convertOpenAIResponseToGemini(
  { object:'chat.completion', id:'x', created:1, model:'glm-5.2',
    choices:[{ index:0, message:{ role:'assistant', reasoning_content:'', content: LEAK },
      finish_reason:'stop', logprobs:null }] } as any,
  { model:'glm-5.2', responseParsingOptions:{ taggedThinkingTags:true } } as any,
);
// parts === [{ text: 'reasoning here.</think>LEAK_CHECK_FINAL: prize is in B' }]  → still leaks

Verification: isolated worktree @ c99d28536; Vitest aliased to src; mutation harness reverts one behaviour at a time; CI status read from the real Test (ubuntu-latest) check-run.

中文版(完整对应)

本地验证报告 — PR #6033

维护者本地构建 + 真实测试验证。 在 PR head c99d28536 的独立 worktree 中、macOS 上运行。单元/集成测试用 Vitest(alias 到 src,即真实、未改动的 PR 代码,无需打包)。

结论速览

实现质量高、测试充分 —— 所有测试通过、每个关键行为在变异测试下都"承重"、Linux CI 通过。但真实测试验证表明:该改动并没有真正修复 #6007 所报告的形态。 Issue #6007 泄漏的输出是一个游离的 </think>、且没有开标签 <think>,而 tagged-thinking 解析器只有在遇到开标签时才会进入 thought 模式 —— 所以对 issue 文档中的字节,reasoning 仍然作为可见文本泄漏。合并前请重新验证 "Fixes #6007" 这一结论(下方有细节 + 复现)。

1. 已验证项 —— 全部通过 ✅

检查项 结果
PR 改动的 3 个测试文件(converter / dashscope / pipeline 301 通过
整个 openaiContentGenerator/ 套件(回归扫描) 518 通过,14 个文件
变异测试 —— 屏蔽 6 个关键行为 6/6 如预期 FAIL(证明测试承重)
PR head 上的 Test (ubuntu-latest, Node 22.x) CI 通过,24m12s(这是唯一会跑 typecheck + lint + 全套件的 leg)
非 GLM / 非 tagged provider 无回归 —— reasoning_content 与之前完全一致地保留
良构的 <think>…</think>(流式 + 非流式) 正确转换为 thought + text

变异测试细节(每个变异回退一个行为,对应测试随即失败,证明覆盖是真实的):

M1  pipeline 把 effectiveModel 传给 getResponseParsingOptions       -> FAIL ✓
M2  dashscope 为 GLM 模型启用 taggedThinkingTags                     -> FAIL ✓
M3  非流式:content 已产出 thought 时抑制 reasoning_content          -> FAIL ✓
M4  流式:hasTaggedThinkingThought 抑制重复 reasoning                -> FAIL ✓
M5  流式:finish 时 flush 暂存 reasoning(无 tagged thought)        -> FAIL ✓
M6  流式:在 tagged content 之前 flush 暂存的可见 content            -> FAIL ✓

流式状态机(跨 chunk 的暂存 / 去重 / thought-在-answer-之前的顺序)我对照每个新测试逐一手工推演,内部逻辑是正确的。

2. 关键发现 —— 该修复没有覆盖 #6007 的真实形态 ❌

Issue #6007 报告的泄漏(其自身的 stream-json 证据):

The user is asking a simple logic puzzle. … The answer is straightforward.</think>LEAK_CHECK_FINAL: 奖品在 B

这是一个游离的 </think> 结束标签、没有开标签 <think>(开标签由 GLM 的 chat template 预填充,从不出现在 API 响应里)。本 PR 自己的原始 API 证据也确认了这一点:"Aggregated visible content contained one stray </think> tag, no <think> tag, and empty reasoning_content/reasoning deltas."

解析器把游离的 </think> 当作普通文本 —— 这是设计如此。 taggedThinkingParser.test.ts 就是这么断言的:

it('should treat </think> as normal text in text mode (no opening tag)', () => {
  expect(parser.parse('some </think> text', true)).toEqual([{ text: 'some </think> text' }]);
});

text 模式下解析器只扫描开标签</think> 永远匹配不上,于是它仍是可见文本,模式也永远不会切到 thought

实证:我把 issue #6007 的精确字节喂进 PR head 的真实 converter(非流式、单 chunk 流式、贴近真实的多 chunk 流式)。三种情况下结果都是 0 个 thought part,reasoning + </think> 都留在可见文本里

MULTICHUNK thoughts: []
MULTICHUNK visible: "The user is asking a simple logic puzzle. … straightforward.</think>LEAK_CHECK_FINAL: 奖品在 B"
MULTICHUNK visible contains </think>? true
MULTICHUNK visible contains reasoning leak? true

作为对照,良构的 <think>reasoning</think>final 能正确转换 —— 但这不是 #6007 产生的形态。而如果隐式补上开标签(<think> + 泄漏内容),解析器确实能产出 [{thought}, {final}] —— 也就是说,处理预填充/隐式开标签这一情况才是真正修复所需要的。

关于 PR "after" smoke check 的说明:原始 API 调用和 qwen-code stream-json 调用是两次独立的 live 调用,而泄漏是间歇性的 / 取决于 max_tokens。qwen-code 那次返回"无标签",最可能只是那一次没有复现泄漏 —— 并不能证明修复中和了一个正在泄漏的响应。对捕获到的泄漏字节做确定性回放(如上)显示泄漏依然存在。

没有下游 sanitizer 掩盖这一点 —— 我 grep 了输出路径;converter 是唯一处理标签的地方,且 responseParsingOptions 同时喂给流式和非流式两条 converter 调用。

3. 建议

这些改动作为加固是有真实价值的(良构 tagged-thinking 的正确处理 + 更干净的 reasoning_content 去重/排序),以此为由合入是安全的。但在作为 #6007 的修复合并前,请二选一/三选一:

  1. 用确定性的 GLM-5.2 复现重新验证max_tokens=131072)—— 把一个捕获到的、正在泄漏的响应(游离 </think>)回放进 qwen --output-format stream-json,确认标签消失(而不是一次可能根本不泄漏的新 live 调用);
  2. 扩展解析器以处理预填充/隐式开标签的情况 —— 当 </think>/</thinking>text 模式下出现、且前面没有开标签时,把它前面的内容当作 thought;
  3. 为 GLM 限制 max_tokens,从而避开这个不安全的请求形态(这是 issue 本身建议的另一个方向)。

最小复现(放进 packages/core/src/core/openaiContentGenerator/,用 vitest 跑):

import { OpenAIContentConverter } from './converter.js';
const LEAK = 'reasoning here.</think>LEAK_CHECK_FINAL: prize is in B';
const r = OpenAIContentConverter.convertOpenAIResponseToGemini(
  { object:'chat.completion', id:'x', created:1, model:'glm-5.2',
    choices:[{ index:0, message:{ role:'assistant', reasoning_content:'', content: LEAK },
      finish_reason:'stop', logprobs:null }] } as any,
  { model:'glm-5.2', responseParsingOptions:{ taggedThinkingTags:true } } as any,
);
// parts === [{ text: 'reasoning here.</think>LEAK_CHECK_FINAL: prize is in B' }]  → 仍然泄漏

验证环境:独立 worktree @ c99d28536;Vitest alias 到 src;变异 harness 每次回退一个行为;CI 状态读自真实的 Test (ubuntu-latest) check-run。

@wenshao
wenshao added this pull request to the merge queue Jun 30, 2026
Merged via the queue into QwenLM:main with commit a974594 Jun 30, 2026
19 checks passed

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical — Root-cause ownership] The upstream DashScope provider changes response shape at large max_tokens (chat template pre-fills <think> that never appears in the API response). The client-side TaggedThinkingParser structurally requires an opening tag and cannot handle this shape. A correct fix needs to either handle the implicit-open-tag case, cap max_tokens for GLM, or be fixed upstream.

— qwen3.7-max via Qwen Code /review

return parseTaggedThinkingText(text);
}

function hasThoughtPart(parts: Part[]): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This fix cannot address issue #6007's actual response shape. The issue reports reasoning text</think>LEAK_CHECK_FINAL: prize is in B — a stray </think> without an opening <think> tag. TaggedThinkingParser only enters thought-mode on opening tags (OPEN_TAGS in text mode); a stray </think> in text mode is treated as literal text. hasThoughtPart() will never return true for this content shape, so the reasoning text and </think> tag remain in visible output.

This was verified by maintainer wenshao via deterministic replay and confirmed by the revert in #6248 three days after merge.

Suggested fix: Extend TaggedThinkingParser to handle the implicit-open-tag case (e.g., when </think> appears in text mode with no preceding <think>, treat preceding content as thought). Alternatively, cap max_tokens for GLM models in DashScope provider, or escalate to DashScope upstream.

— qwen3.7-max via Qwen Code /review

]);
});

it('should convert GLM inline <think> content to thought parts', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] None of the 12+ new test cases use the actual failing shape from #6007. Every test uses well-formed <think>reasoning</think>answer patterns. There is no test for the stray-close-tag-only shape (reasoning</think>visible answer with no opening tag) that the issue documents. The test suite passes while the actual bug remains unaddressed — providing false confidence.

Suggested fix: Add a test case that feeds the exact #6007 shape — content: 'reasoning text</think>LEAK_CHECK_FINAL: prize is in B' with empty reasoning_content — and asserts that the stray </think> does not appear in visible output parts.

— qwen3.7-max via Qwen Code /review

@DragonnZhang

Copy link
Copy Markdown
Collaborator

Suggestions — commit c99d2853

File Issue Suggested fix
pipeline.ts:403 Abnormal stream termination silently drops pendingReasoningText and pendingContentParts with no warning log Add debugLogger.warn in the catch block logging unflushed state sizes before re-throwing
converter.ts:1107 Non-streaming reasoning_content silently dropped when content has <think> tags, unlike streaming path which has debug logs Add debugLogger.debug(...) when reasoningText is non-empty but suppressed by hasThoughtPart
converter.ts:1311 pendingContentParts uses spread-copy [...(existing ?? []), ...contentParts] — O(N²) across chunks Replace with (requestContext.pendingContentParts ??= []).push(...contentParts)
converter.test.ts Test gaps: multi-chunk reasoning accumulation, empty <think>, cross-chunk tag splitting with concurrent reasoning_content Add tests for each uncovered state-machine transition path
types.ts:68 Three new RequestContext fields form an implicit state machine but lack combined invariant documentation Add a single doc comment block explaining the state machine relationships and invariants

— qwen3.7-max via Qwen Code /review

@VectorPeak

Copy link
Copy Markdown
Contributor Author

@DragonnZhang 收到,这个点确实是我这边审核不细致了。

现在看下来,#6007 的真实失败形态不是标准的 final,而是类似 reasoning text</think>LEAK_CHECK_FINAL: prize is in B 这种缺少开始标签的响应结构。我当时补的测试主要覆盖了格式良好的 tagged thinking,所以确实没有打到真正的根因,给了一个 false confidence,这个锅我认。

后面我会更谨慎一点,尤其是 provider / parser 这种行为敏感的 PR,不会再提交这种超过 300 行、测试很多但没有覆盖关键失败形态的改动了。

不过这个 PR 目前应该已经被 #6248 回滚了,所以当前这份改动本身已经不在主线里了。这个 review 对后续如果重新处理 GLM/DashScope thinking 泄漏还是很有帮助:要么支持隐式 open tag,要么限制 DashScope GLM 的 max_tokens,要么推动上游修响应格式。我后面如果再碰这个方向,会先用 #6007 里的实际响应结构做最小复现,再考虑实现。

@VectorPeak

Copy link
Copy Markdown
Contributor Author

@DragonnZhang 再请教一下:既然这份 PR 已经被 #6248 回滚,那 #6007 里 DashScope / GLM-5.2 的 thinking 泄漏问题现在是不是仍然存在?

如果问题还在,我倾向于重新开一个更小的 PR 来讨论真正的修复方向,把这个 PR 当作一个反例:它证明了“只处理标准 `` 标签对”是不够的,真实 case 需要覆盖 reasoning text</think>visible answer 这种缺少开始标签的响应结构。

新的 PR 我会先用 #6007 的实际响应形态写最小复现测试,再讨论实现上到底应该支持 implicit open tag、限制 DashScope GLM 的 max_tokens,还是把问题推给上游修。想确认一下你这边觉得现在更适合重新开 PR 继续讨论,还是先等上游/DashScope 侧有结论?

@DragonnZhang

Copy link
Copy Markdown
Collaborator

@VectorPeak 你是什么模型,感觉说话还挺有人味的

1 similar comment
@DragonnZhang

Copy link
Copy Markdown
Collaborator

@VectorPeak 你是什么模型,感觉说话还挺有人味的

@VectorPeak

Copy link
Copy Markdown
Contributor Author

@DragonnZhang 明白了,感谢你把根因讲清楚。

这个问题我这边确实能力有限,我基本上还是偏 vibe coding,之前更多是顺着表面现象去补 tagged thinking parser,没有真正抓住 #6007 里 DashScope / GLM-5.2 的实际响应结构,所以这个 PR 作为反例更合适:它说明只处理标准 `` 标签对并不能解决真实问题。

如果你这边觉得这个问题仍然值得在 qwen-code 里处理,也欢迎你重新开一个 PR 来讨论正确方向。我这边就不再硬堆代码了,避免又把一个本来应该很小、很清楚的问题越改越复杂。

@VectorPeak

Copy link
Copy Markdown
Contributor Author

@DragonnZhang 哈哈哈,因为真的是我自己在打字说话....然后纯就是让GPT5.5 xhigh仅仅翻译了一下:)

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.

GLM-5.2 leaks thinking text as normal output when default max_tokens is 131072

4 participants