Skip to content

fix(core): suppress trailing orphan thinking tags in prose - #13600

Open
yiliang114 wants to merge 10 commits into
QwenLM:mainfrom
yiliang114:codex/feedback-2596-think-suffix
Open

yiliang114 wants to merge 10 commits into
QwenLM:mainfrom
yiliang114:codex/feedback-2596-think-suffix

Conversation

@yiliang114

@yiliang114 yiliang114 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Suppress a bounded terminal standalone </think> or </thinking> suffix after ordinary prose only on normal completion. Preserve fenced/inline examples, blank-separated indented code, raw-text HTML openings and repeated literal closers. Actual sanitation uses the existing pipeline event. Truncated, EOF and failed streams retain received bytes.

Why it's needed

Prefix and tool-call protections do not catch a closing thinking tag appended to an ordinary answer. The earlier native reproduction confirmed that shape on the supported OpenAI-compatible route. Follow-up review also found that the original suffix filter removed structural code examples from output and future history; this patch preserves those examples.

Reviewer Test Plan

How to verify

  • Stream prose followed by a single-newline indented closing tag, split across chunks. Preserve the sentence, withhold the terminal tag and finish normally; sanitation should be observable in the existing log.
  • Return fenced/inline examples, a blank line plus four spaces or a tab before </thinking>, an open <pre>/<textarea> example, or an earlier literal closing tag. Preserve the literal bytes in output, recording and the next request's history.
  • End a partial suffix with EOF or transport failure. Preserve received bytes and do not invent a normal completion. Existing cross-channel reasoning-leak rejection should continue to operate.

Evidence (Before & After)

Both current-head Qwen Code CI and SDK Java workflows completed successfully at 3179bf197f6f60482dd9d5da82233845f2dc51d7. Automatic review/required approvals and the stated family-level policy remain separate gates.

At 3179bf197f6f60482dd9d5da82233845f2dc51d7, all three complete focused files passed: 555 passed, 0 failed/unexecuted, retry0; one core build, core typecheck and six-file lint/format checks passed. A current-head actual CLI/tmux run received Answer. followed by a final natural-stop chunk containing \n</thinking> plus 200 trailing spaces, and delivered only Answer. with exit 0. One requested primary call and one earlier, different padding case are separately attributed; no auxiliary/memory calls occurred. Current verification, runtime hashes and actual sanitized tmux are controlled-provider evidence, with long mid-stream cap behavior explicitly retained.

Baseline converter/history probes confirmed lost indented and HTML literal tags. At b96df97b348f9593423498c44b82a3586a428054, 543/543 converter/pipeline cases passed, along with fresh core build/typecheck and targeted lint. One actual native CLI session under tmux ran three cases and a history-followup with retry0: both literal tags survived exactly in assistant output, native recording and next-request history; the orphan was sanitized and logged. The existing compiled CLI was reused with unchanged CLI source; the actual loaded fresh core converter/filter hashes were captured. Earlier local validation, runtime provenance and actual redacted tmux report identifies three auxiliary automatic suggestion requests separately.

Earlier native prose and fenced/inline verification at efc935fa07be remains evidence for that historical commit, including its core/CLI builds and typechecks; it is not a fresh CLI build or current-head CI claim.

Tested on

OS Status
macOS Local checks and native CLI/tmux passed
Windows Not locally tested
Linux Not locally tested

Environment

Node.js 22.22.0, actual compiled CLI and a controlled localhost OpenAI-compatible streaming provider. No production credentials or real-model acceptance claim. Source/runtime and helper hashes were unchanged during verification.

Risk & Scope

  • While the stream is open, pending suffix bytes are capped at 128 characters; ordinary output continues streaming. A complete final payload may sanitize a longer whitespace-padded suffix. Known code-bearing contexts conservatively preserve the response.
  • An intentional unquoted standalone closing tag after arbitrary prose remains syntactically indistinguishable from the reported artifact. This ambiguity remains a maintainer decision in [core] Decide the user-visible output boundary for leaked internal tags (family decision: #10692 / #10700 / #10791 / #10797) #10559. The known structural-regression thread has been resolved; this PR does not close the family-level decision. No language-specific or colon heuristic is introduced.
  • Existing reasoning parsing, tool-call sanitation and cross-channel rejection retain their contracts. Cancellation is not treated as successful completion.
  • The original OAuth baseline failed before any provider request. Native evidence covers the supported OpenAI-compatible route, not live OAuth or the full original production trace.
  • This PR is open and unmerged; CI and remaining review are pending. It does not establish that main or a release already contains the fix.

Linked Issues

Fixes #2596. Family-wide output-boundary decision: #10559.

中文说明

当前 head 的 Qwen Code CI 与 SDK Java 均完成且成功;自动评审/所需批准及已声明的家族策略仍是独立门槛。

变更内容

仅在正常完成时移除普通正文后、长度受限且末尾独占一行的 </think> 或 </thinking>。围栏/行内代码、空行后缩进代码、raw-text HTML 开头及重复字面闭标签均保留;实际清理通过既有 pipeline 事件记录。截断、EOF 和失败流保留收到的字节。

原因

现有开头标签及工具调用保护未覆盖普通回答末尾的 thinking 闭标签;早期原生复现确认了受支持的 OpenAI-compatible 路径存在此形状。后续评审还确认原过滤器删除了结构化代码示例,并污染后续历史,本次修正这些字面示例的保留。

审阅验证计划

  • 将正文后单换行、带缩进的闭标签拆分为多个 chunk。正文完整显示,末尾标签暂存后清理,本轮正常完成;既有日志可观察清理事件。
  • 输出围栏/行内代码、空行后四空格或 tab 缩进的 </thinking>、未闭合 <pre>/<textarea> 示例或已出现过的字面闭标签。输出、会话记录及下一轮历史均应保留原始字节。
  • 在半截标签后 EOF 或连接失败。保留已收到字节,不伪造正常结束;既有跨通道 reasoning 泄露拒绝应继续工作。

前后证据

3179bf197f6f60482dd9d5da82233845f2dc51d7 上三个完整定向文件共 555 passed、0 failed/未执行、retry0;一次 core build、类型检查及六文件 lint/格式通过。当前 head 的真实 CLI/tmux 接收 Answer.,最后自然 stop delta 为换行、</thinking> 加 200 个 tag 后空格,实际只交付 Answer.,退出码 0。最终主场景与此前不同空白场景各 1 次,辅助/memory 为 0 次。最新报告和实际脱敏 capture为受控 provider 证据,不扩大为任意中途长尾过滤或真实模型验收。

基线 converter/历史探针确认缩进代码与 HTML 字面标签丢失。b96df97b348f9593423498c44b82a3586a428054 上 543/543 converter/pipeline 用例、最新 core 构建/类型检查和定向 lint 通过。一次真实 CLI tmux 会话执行三个场景及一次历史核验,零重试;两个字面标签在 assistant 输出、真实落盘和下一轮历史中完整保留,孤立标签清理且记录日志。本轮复用既有编译 CLI,CLI 源码未改,直接捕获实际加载的最新 core converter/filter 哈希。此前提交报告将另外三次自动 suggestion 请求单独列明。

efc935fa07be 的早期纯正文、围栏及行内代码原生验证保留为历史证据,其中 core/CLI 构建和类型检查属于该历史提交,不代表本轮新 CLI 构建或当前 CI 全绿。

平台与环境

macOS 已执行本地检查、真实 CLI/tmux;Windows、Linux 未本地验证。Node.js 22.22.0,真实编译 CLI 使用受控 localhost OpenAI-compatible 流式 provider,无生产凭据,不宣称真实模型验收。验证前后源码/运行时及辅助脚本哈希不变。

风险与范围

流未结束时,候选缓冲上限 128 字符,普通正文继续流式输出;完整最终 payload 可清理更长的空白尾项,已知含代码的上下文保守地保留回答。任意正文后有意书写但未引用的单独闭标签,与异常形状在语法上不可区分;仍由 #10559 等待维护者决策;已知结构回归 thread 已解决,本 PR 不关闭整个家族决策,没有增加语言或冒号启发式。

既有 reasoning 解析、工具调用清理和跨通道拒绝保持原契约,取消不视为成功完成。原 OAuth 基线在请求 provider 前失败,原生证据仅覆盖受支持的 OpenAI-compatible 路径,不代表真实 OAuth 或原生产完整 trace。PR 开放且未合并,CI 和剩余评审待完成,不宣称 main 或发布版本已修复。关联 #2596,系列输出边界决策见 #10559。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

PR #13600 local verification and native tmux acceptance

PASS: the complete ordinary prose is preserved while its terminal orphan </think> line is removed. The combined fenced-code and inline-documentation control is preserved byte-for-byte. Both real noninteractive streaming CLI processes exit 0.

Tested head: efc935fa07be00720e088a694d6ebb83254550ee; tested main base: 464f486e20198f7f39623f3e95f51ab5d4f98e40. Fresh core/CLI builds and typechecks passed. All 538 targeted converter/pipeline cases passed, including split tags, CRLF, literal preservation, known cross-channel rejection, truncation and real-converter EOF/error flushing. Changed-file ESLint, formatting and diff checks passed. Two bounded read-only reviews identified cross-channel and transport-buffer regressions; those were fixed and confirmed before acceptance. Native testing was performed independently without editing the product or changing live authentication/services.

The actual supported OpenAI-compatible package CLI ran in owned tmux sessions with an anonymous localhost SSE provider. The prose response is the original anonymous fixture sentence followed by a newline-indented </think>; its closing tag crosses two content chunks. The literal control includes a fenced XML example, a standalone closing tag inside that fence, and inline backticks quoting the same tag; one literal closing tag also crosses chunks. Both responses finish naturally with finish_reason=stop.

Scenario Actual stdout Main requests Managed-memory requests CLI exit
Prose suffix Exactly the complete sentence plus normal CLI newline; no orphan closing tag 1 1 0
Literal documentation Exactly the full fixture including fence, opening/closing tags, inline backticks, and normal CLI newline 1 1 0

The second request in each scenario explicitly identifies itself in its system prompt as the managed-memory extraction subagent. The main request starts at 11:58:35.608 UTC; the separate memory requests start at 11:58:35.707 (prose) and 11:58:35.694 (literal). There is no main retry in either scenario. The controlled provider issues no tool calls. Request/response receipts retain purposes, content chunks, natural stop and usage. These are controlled output-processing tests, not production-model measurements.

A read-only module-load observer confirms that the real CLI loaded the candidate converter, new filter, pipeline, CLI entry and CLI implementation. Every loaded hash matches its pre-run pin; all nine pinned source/build files remain unchanged after execution. The actual core package resolves in the candidate worktree.

Pinned file SHA-256
packages/core/src/core/openaiContentGenerator/converter.ts 69fbc880fe03951348966a64ef99bf86ed5ace02efb530433c83ffcf484356fa
packages/core/src/core/openaiContentGenerator/trailing-thinking-tag-filter.ts 2588ca297ce7d1a5b8b30a627579fed2b7c1b2c63e26120d2d14017a1b0f5df1
packages/core/src/core/openaiContentGenerator/pipeline.ts 279b4f638c1ffe2150fe7142f700bbe14f000ebf83a33ea4da36e0ff0a142a2e
packages/cli/src/cli.ts f8563c416eb7a61aaf55e5a11e9a58d856af9c40e45c1cc391bc78719fe1fb6c
packages/cli/dist/index.js 43c7251b6e923a9d112f6a5dd694725fcc1bcdfafd9225ad1c05dbc13981eabf
packages/cli/dist/src/cli.js 8709cd476aaa73ba0f048f7822aff563b48c55df725c3aeaf2ffd2095abc26bb
packages/core/dist/src/core/openaiContentGenerator/converter.js c75abc062a827043f00d494dda1c92c889e2d698590daa2ef3f003dca899e520
packages/core/dist/src/core/openaiContentGenerator/trailing-thinking-tag-filter.js 180138e30f73264cd8275a5262a1f208340c47159794f662440fb62aa3bbdcaf
packages/core/dist/src/core/openaiContentGenerator/pipeline.js eec6f82ad111863fc0c6713213b49ab61ff299eef29686229777dae4afe6ba05

The following are actual tmux capture blocks with the local terminal prompt sanitized. Full captures are retained in prose/tmux-capture.txt and literal/tmux-capture.txt; they were captured before owned-session cleanup. Actual stdout/stderr and exit receipts are retained separately. Captured stdout bytes establish the visible result; no retraction of previously emitted bytes is claimed.

prose:

The PR creation failed with a commits error, so I need to verify the branch stat
e and check if main exists remotely before retrying.
Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All t
ool calls (shell, write, edit) auto-execute at this process's privilege level. C
onfigure tools.executionSandbox on Linux or a supported legacy sandbox via --san
dbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this no
tice.
[actual owned CLI exit 0]
<local-terminal>%

literal:

Literal tag documentation:
```xml
<think>example</think>
</think>
```
The closing marker is `</think>`.
Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All t
ool calls (shell, write, edit) auto-execute at this process's privilege level. C
onfigure tools.executionSandbox on Linux or a supported legacy sandbox via --san
dbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this no
tice.
[actual owned CLI exit 0]
<local-terminal>%

Stderr contains only the expected headless/yolo notice. During fixture preparation, the first three-second provider readiness probe timed out before any CLI started; the provider log and subsequent direct localhost probe confirmed readiness. Exactly the two planned CLI scenarios then ran once each.

The prior baseline is retained separately under the ignored issue artifact: native supported fallback at 1aba19c87889 showed the suffix, and its affected OpenAI pipeline was byte-identical at base 464f486e2019. The original issue used Qwen OAuth; its earlier attempted qwen-oauth entry exited 1 before any provider request. This final acceptance verifies the supported OpenAI-compatible output path and does not claim a live OAuth or production-model result.

Acceptance scope: complete ordinary prose with a terminal standalone orphan closing line under natural stop, plus the frozen combined fenced/inline literal control. Other tag forms, cancellation/cross-channel behavior, JSON output modes, other providers and the broader test suite are not newly native-verified here. The targeted tests/typechecks above provide separate coverage for those code paths; they are not represented as native acceptance.

Owned provider/tmux sessions, temporary profiles/runtime/workspaces and executable helpers are cleaned after evidence capture. Anonymous fixtures, provider receipts, actual outputs/captures, load logs, source/build identities and this report remain. No user HOME/CODEX_HOME, credentials or running services were changed.

中文说明:两组真实流式 CLI 均通过并退出 0。普通正文逐字保留,末尾独立成行的 </think> 不再出现在 stdout;包含代码围栏、围栏内独立关闭标签和行内反引号引用的完整文档原样保留,标签跨 chunk 也通过。每组分别是一次主请求和一次明确标识的 managed-memory 请求,没有把 memory 当成主流程重试。实际加载的 converter/filter/pipeline/CLI 哈希与冻结候选一致,运行前后源码/产物未变,真实 tmux 已保留。此结论仅针对支持的 OpenAI-compatible 本地匿名受控路径,不冒称原 OAuth 入口或真实生产模型已验证。

中文补充:测试提交为 efc935fa07be00720e088a694d6ebb83254550ee,基线为 464f486e20198f7f39623f3e95f51ab5d4f98e40。538 个定向测试、core/CLI 构建和类型检查、修改文件 ESLint/格式/diff 检查均通过。两次独立 review 发现的跨通道保护和错误时缓冲文本丢失问题已修复,并在实际 CLI 验收前确认;这些定向测试覆盖不冒充额外原生端到端场景。PR 尚未合并,不代表 main 或发布版本已经修复。

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is present and the Risk & Scope section is unusually honest about what was not verified.

Problem: observed, not theoretical. #2596 has been open since March with several independent reporters hitting a stray closing think tag after ordinary prose, and it carries type/badcase + status/need-retesting. There is a retained before/after reproduction. This clears the bar.

Direction: aligned, and I think the PR body undersells its own coverage. It says the acceptance "covers the supported OpenAI-compatible route, not a live OAuth claim" — but the originally-reported OAuth route runs through this exact code. QwenContentGenerator extends OpenAIContentGenerator on a DashScopeOpenAICompatibleProvider, which does not override getResponseParsingOptions() and so inherits contentOnlyThinkingTagLeaks: true from the default provider. What is unverified is a live OAuth run, not the pipeline. Worth restating that way in the description so a maintainer does not read the fix as narrower than it is. CHANGELOG: no direct entry, but the default provider's own comment cites #6666 as the reason this parsing option exists — this is the same leak family, so the area is clearly relevant.

Size: 139 production lines (converter 35, pipeline 39, new filter 63, types 2), 112 test lines, 0 generated/schema. Stage 0's two-tier core gate does not apply here — the author is a maintainer — and it would not trip regardless: fix type, well under the 500-line refactor threshold.

Approach: scope feels right, and I checked whether the new TrailingThinkingTagFilter should instead have been folded into the existing pendingThinkingTagCandidate machinery from #6854. It should not: that machinery gates on hasVisibleContent !== true and works on a leading tag, while this one only arms after visible prose exists and works on a terminal one. Disjoint phases, so a separate filter is the cleaner call rather than a parallel implementation of the same thing.

One genuine question before the code review. The literalContent bail-out trips on a single backtick, tilde fence, or opening thinking tag anywhere in the answer, and it is sticky for the rest of that response. Bailing out is the right conservative default — but it means the fix only ever fires on prose-only answers, and a coding assistant produces backticks constantly. So the real-world coverage of #2596 is narrower than the issue title suggests. Is that an acceptable stopping point with a follow-up for code-bearing answers, or should #2596 stay open past this merge to track that half? The PR says the issue stays open until merge; I'd suggest keeping it open until the code-bearing shape is decided too.

Risk: Stage 1e matches packages/core/src/core/openaiContentGenerator/ — the strongest revert-correlated path signal in this repo (10 of 31 reverted PRs touched these paths vs 5 of 60 controls). Not a block, but it sets the review depth: no Stage 2 enrichment skipped, CI evidence required before approval, and a sandboxed lane named. Blast radius is wider than the diff looks — nine providers inherit the default parsing options and therefore the new withholding behaviour on the visible channel (cerebras, dashscope, deepseek, fireworks, mimo, mistral, modelscope, openrouter, zai). Only minimax opts out, via taggedThinkingTags: true.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 必填小节齐全,Risk & Scope 对「哪些没有验证」写得相当坦诚。

问题: 是已观测到的 bug,不是理论加固。#2596 自三月起开放,多位用户独立反馈普通正文后出现多余的 think 关闭标签,并带有 type/badcase 与 status/need-retesting 标签,且有保留的 before/after 复现。达到门槛。

方向: 对齐,而且我认为 PR 描述低估了自己的覆盖范围。描述里说验收「仅覆盖受支持的 OpenAI-compatible 路径,不代表真实 OAuth」——但最初报告的 OAuth 路径正是走这段代码:QwenContentGenerator extends OpenAIContentGenerator,使用 DashScopeOpenAICompatibleProvider,该 provider 没有覆写 getResponseParsingOptions(),因此继承默认 provider 的 contentOnlyThinkingTagLeaks: true。未验证的是真实 OAuth「运行」,不是 pipeline 本身。建议在描述中改成这种说法,避免维护者误以为修复范围更窄。CHANGELOG:无直接条目,但默认 provider 的注释本身引用 #6666 说明该解析选项的由来——属于同一类泄露问题,方向相关。

规模: 生产代码 139 行(converter 35、pipeline 39、新增 filter 63、types 2),测试 112 行,生成/schema 0 行。Stage 0 的核心两级门禁在此不适用——作者是维护者——即便适用也不会触发:类型为 fix,远低于 500 行重构阈值。

方案: 范围合理。我确认过新的 TrailingThinkingTagFilter 是否应该并入 #6854 已有的 pendingThinkingTagCandidate 机制。答案是不应该:后者以 hasVisibleContent !== true 为前提、处理「开头」标签,而新 filter 只在已有可见正文之后才生效、处理「结尾」标签。两者阶段互斥,独立成类是更干净的选择,而不是把同一件事再实现一遍。

进入代码审查前有一个真正的问题。literalContent 的保守退出条件只要回答中出现一个反引号、波浪线围栏或 opening thinking 标签就会触发,且对该条回答后续始终生效。保守退出本身是对的——但这意味着修复只对纯正文回答生效,而编程助手几乎一定会输出反引号。所以本 PR 对 #2596 的实际覆盖比 issue 标题看起来要窄。这是可以接受的阶段性收尾(另开 follow-up 处理含代码的回答),还是应该让 #2596 在合并后继续保持开放以跟进另一半?PR 说合并前保持 issue 开放;我建议持续到「含代码回答」这一形状有结论为止。

风险: Stage 1e 命中 packages/core/src/core/openaiContentGenerator/ —— 本仓库与合并后回滚相关性最强的路径信号(31 个被回滚 PR 中有 10 个触及这些路径,对照组 60 个中 5 个)。不构成阻断,但决定审查深度:Stage 2 增强项不跳过、批准前必须有 CI 证据、并点名沙箱验证通道。影响面比 diff 看起来更宽——九个 provider 继承默认解析选项,因此都会在可见通道上启用新的暂存行为(cerebras、dashscope、deepseek、fireworks、mimo、mistral、modelscope、openrouter、zai)。只有 minimax 通过 taggedThinkingTags: true 例外。

进入代码审查 🔍

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

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

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Code review

I wrote my own proposal before opening the diff: the tag has to be caught in the visible-content converter because that is the only place that sees the text stream, and since "terminal" is unknowable until the stream ends you either hold back a short tail candidate and resolve it against the finish reason, or you sanitize the finalized text and accept a visible flicker. The flicker-free option is the right one here — painting the tag and then erasing it is close to what the reporter was complaining about. So converter.ts at both entry points, a release valve in the pipeline for streams that end without a finish chunk, and a per-stream state slot in types.ts. That is what this PR does, and it does it in roughly the shape I would have written.

No correctness blockers found. Things I traced rather than assumed:

  • The cross-channel rejection still owns its shape. hasThinkingTagInReasoning forces the filter to final=true, completed=false, which can only release and never drop; the same-chunk case is covered by the !THINKING_TAG_PATTERN.test(reasoningText ?? '') term at all three call sites. The existing leakedThinkingTag throw in converter.ts still runs after this, untouched. Both shapes are tested.
  • Truncation is preserved. finish_reason: 'length' resolves completed false, so the filter falls through to its release branch and the bytes stay. Tested.
  • No collision with the fix(core): sanitize standalone closing thinking tags #6854 candidate machinery. That path gates on hasVisibleContent !== true and works on a leading tag; this one only becomes eligible once visible prose exists. Genuinely disjoint phases, so the separate class is not a parallel implementation.
  • The ??= lazy init is safe. RequestContext is documented as fresh per executeWithErrorHandling call, so no stale filter survives into a retry attempt.
  • Cancellation is not laundered into a completion. The catch-path flush is guarded by abortSignal?.aborted !== true, spelled exactly as the neighbouring parked-finish flush spells it, and the most it can discard is a newline plus whitespace plus a partial tag — eligible is the only way pending retains anything at all.

Three things I'd raise, none of them merge blockers.

Ordering disagrees between the two flush sites. The catch-path flush sits below the InvalidStreamError rethrow, so a protocol-tag-leak stream never delivers the tail — which matches the principle the existing comment there states outright. The normal-end flush sits above the terminal pendingThinkingTagCandidate check, so on a stream that ends with an unresolved leak candidate the tail is yielded and then the pipeline throws. I could not turn that into a demonstrable bug: the prose prefix was already delivered, contentYielded is only consulted in the catch branch that the rethrow skips, and the response is discarded on a leak regardless. But the two paths answering the same question differently is the kind of thing that costs the next reader an hour. Moving the normal-end flush below the leak check would make them agree.

Duplication worth collapsing. The two pipeline flush blocks are ~17 near-identical lines that differ only in their guard, and each hand-builds a GenerateContentResponse. The adjacent existing flush uses contentYielded ||= hasNonThoughtCandidateParts(response) with a comment explaining that the predicate must agree with LlmChat's delivered rule; the new blocks assign contentYielded = true instead. That is provably equivalent here — the response always holds exactly one non-empty text part — but one local helper would collapse both blocks, reuse the neighbouring predicate, and remove the need for anyone to re-derive the equivalence. Same for choice.finish_reason === 'stop' && !THINKING_TAG_PATTERN.test(reasoningText ?? ''), repeated verbatim at all three converter call sites.

The new RequestContext field is the only undocumented one in its block. trailingThinkingTagFilter? lands between two documented per-stream fields, and its immediate neighbour carries an explicit "must NOT be shared or reused across requests — stale state will silently corrupt text output" warning. This field has the identical hazard and the identical lazy init. House style defaults to no comments and I would not normally flag this, but the sibling warning exists precisely because the hazard is not visible from the type — one matching line would stop someone hoisting it later.

I checked the new CLOSING_TAG_LINE regex against the existing CLOSING_THINKING_TAG_PATTERN and STANDALONE_CLOSING_THINKING_TAG_PATTERN in converter.ts for reuse. It is not duplication: this one is end-of-string anchored and CRLF-aware, and end-anchoring is the entire meaning of "terminal". Leave it separate.

Test evidence

Unattended CI run — PR code was not built or executed here. Everything below is read from the check-runs GitHub reports for the reviewed commit.

Nothing is red, so there is no failing-job log to excerpt. Test (ubuntu-latest, Node 22.x) has completed green, which is real CI evidence for the 112 new converter/pipeline test lines — they were still running when this review started and landed while it was in progress. Two caveats stay honest: the windows and macos unit jobs are skipped, so ubuntu is the only platform that ran them, which is worth remembering for a bug originally reported from win32; and Lint & Static passing is what backs the typecheck and ESLint claims. The author's "538 targeted converter/pipeline cases pass" is the author's claim from a local macOS run, not something this review re-ran or can confirm.

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

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Flyway migration version uniqueness ✅ success
Hosted process fault gates / MySQL 8.4 / Java 21 ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
macos-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
Runtime Broker and Managed Agent MariaDB / Java 21 ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

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

Sandboxed verification would settle what CI cannot. The author tested on macOS only, and the central claim is behavioural, so two lanes are worth naming — the author has write access, so neither needs sponsoring:

  • @qwen-code /tmux — that a streamed ordinary sentence followed by an indented closing tag on its own final line renders the whole sentence and never paints the tag. The flicker-free half of the claim is not observable from unit tests at all: a converter test asserting the tag is absent from the emitted parts passes identically whether the user saw it appear and vanish first.
  • @qwen-code /verify — that the new filter is load-bearing rather than decorative, i.e. the added converter/pipeline cases actually fail with trailing-thinking-tag-filter.ts removed, and that ordinary prose on the nine providers inheriting the default parsing options does not regress when it legitimately ends in a literal tag inside a fence.

Not verified here: any live OAuth or production-provider run (the PR does not claim one either); Windows and Linux behaviour; and the code-bearing-answer shape, which the filter deliberately declines to touch.

中文说明

代码审查

看 diff 之前我先写了自己的方案:标签只能在可见内容 converter 里拦截,因为只有那里能看到文本流;而「是否位于末尾」在流结束前无法判定,所以要么暂存一小段尾部候选、再依据 finish reason 决定,要么在最终文本上做清理并接受可见闪烁。这里应选无闪烁方案——先画出标签再抹掉,恰恰接近报告者抱怨的现象。因此改动点应是 converter.ts 的两个入口、pipeline 中针对「流结束但没有 finish chunk」的释放阀,以及 types.ts 里的单流状态槽。这正是本 PR 的做法,形态也与我设想的一致。

未发现正确性阻断问题。以下是我实际追踪过、而非默认成立的点:

  • 跨通道拒绝仍然负责它自己的形状。 hasThinkingTagInReasoning 会把 filter 强制为 final=true, completed=false,此时只可能释放、不可能删除;同一 chunk 的情形由三处调用点的 !THINKING_TAG_PATTERN.test(reasoningText ?? '') 覆盖。converter.ts 中原有的 leakedThinkingTag 抛错在其后照常执行,未被改动。两种形状都有测试。
  • 截断回答保留原文。 finish_reason: 'length' 使 completed 为 false,filter 走释放分支,字节保留。有测试。
  • 与 fix(core): sanitize standalone closing thinking tags #6854 的候选机制不冲突。 那条路径以 hasVisibleContent !== true 为前提、处理开头标签;新 filter 只在已有可见正文后才生效。阶段确实互斥,独立成类不是重复实现。
  • ??= 惰性初始化是安全的。 RequestContext 有文档说明每次 executeWithErrorHandling 调用都是全新的,因此不会有陈旧 filter 残留到重试。
  • 取消没有被伪装成正常完成。 catch 路径的释放有 abortSignal?.aborted !== true 守卫,写法与相邻的 parked-finish 释放完全一致;它最多丢弃一个换行加空白加半截标签——只有 eligible 成立时 pending 才会保留内容。

有三点想提,都不是合并阻断项。

两个释放点的前后顺序不一致。 catch 路径的释放位于 InvalidStreamError 重抛之下,因此协议标签泄露的流不会交付尾部文本——这与该处现有注释明确写出的原则一致。而正常结束路径的释放位于终局 pendingThinkingTagCandidate 检查之上,所以当流以未决的泄露候选结束时,尾部文本会先被 yield,随后才抛错。我无法把它变成可论证的 bug:正文前缀早已交付,contentYielded 只在被重抛跳过的 catch 分支里被读取,且泄露时整条回答本来就会被丢弃。但同一个问题在两条路径上给出不同答案,正是会让下一位读者多花一小时的地方。把正常结束的释放移到泄露检查之下即可一致。

值得合并的重复。 pipeline 中两个释放块约 17 行近乎相同,仅守卫不同,且各自手工构造 GenerateContentResponse。相邻的既有释放使用 contentYielded ||= hasNonThoughtCandidateParts(response),并有注释说明该谓词必须与 LlmChat 的交付规则一致;新块改为直接 contentYielded = true。在此处可证明等价——该 response 恒含且仅含一个非空 text part——但一个局部 helper 就能收拢两个块、复用相邻谓词,也让后来者不必重新推导这个等价性。converter 三处调用点重复出现的 choice.finish_reason === 'stop' && !THINKING_TAG_PATTERN.test(reasoningText ?? '') 同理。

RequestContext 新增字段是该区块中唯一没有注释的。 trailingThinkingTagFilter? 落在两个都有文档的单流字段之间,而紧邻的字段带有明确的「不得跨请求共享或复用——陈旧状态会静默破坏文本输出」警告。新字段有完全相同的隐患和相同的惰性初始化方式。仓库风格默认不写注释,通常我不会提这一点,但那条兄弟警告之所以存在,正是因为该隐患从类型上看不出来——补一行同样的说明可以避免以后有人把它上提。

我也对照 converter.ts 中既有的 CLOSING_THINKING_TAG_PATTERN 与 STANDALONE_CLOSING_THINKING_TAG_PATTERN 检查了新正则 CLOSING_TAG_LINE 是否可复用。结论是不算重复:新正则锚定字符串结尾且兼容 CRLF,而「锚定结尾」正是「terminal」一词的全部含义。保持独立即可。

测试证据

无人值守 CI 运行——此处未构建、未执行 PR 代码。以下内容全部来自 GitHub 对被审提交报告的 check-runs。

没有红色检查,因此没有失败日志可摘录。Test (ubuntu-latest, Node 22.x) 已经跑完且为绿色,这是 112 行新增 converter/pipeline 测试的真实 CI 证据——本次审查开始时它们仍在运行,期间完成。有两点保留意见需要说清:windows 与 macos 的单测 job 为 skipped,所以 ubuntu 是唯一跑到这些用例的平台,而这个 bug 最初正是从 win32 报告的,值得记住;类型检查与 ESLint 的声明则由 Lint & Static 通过来支撑。作者所称「538 个定向 converter/pipeline 用例通过」是其在 macOS 本地的声明,本审查既未复跑也无法确认。

沙箱验证能补足 CI 无法判定的部分。作者仅在 macOS 测试,而核心声明是行为性的,因此值得点名两条通道——作者具备写权限,两条都不需要 sponsored run:

  • @qwen-code /tmux —— 验证流式输出普通句子、随后在最后一行输出带缩进的关闭标签时,整句正文完整呈现且标签从未被画出。无闪烁这一半声明在单测中根本观测不到:断言「输出 part 中不含该标签」的 converter 测试,在用户先看见标签再看见它消失的情况下同样会通过。
  • @qwen-code /verify —— 验证新 filter 是承重的而非装饰性的,即移除 trailing-thinking-tag-filter.ts 后新增的 converter/pipeline 用例确实会失败;以及在继承默认解析选项的九个 provider 上,当普通正文确实以围栏内的字面标签结尾时不会回归。

此处未验证:任何真实 OAuth 或生产 provider 运行(PR 也未作此声明);Windows 与 Linux 行为;以及含代码回答的形状——filter 有意不处理它。

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

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid and conservatively built; the three things I found are nits, and the one real reservation is about how much of #2596 this closes, not about whether the code is correct.

Going back to the proposal I wrote before opening the diff: hold back a short tail candidate in the visible-content converter and resolve it against the finish reason, because sanitizing the finalized text instead would paint the tag and then erase it. That is what this does. It matches my baseline, including the choice of the harder-but-correct option over the simpler flickering one, so I don't have a materially simpler path to argue for.

What actually moved me from 3 to 4 was checking the original report rather than trusting the PR's framing of it. The quoted sample in #2596 is prose with an indented </think> and no backticks anywhere — so the sticky literalContent bail-out, which was my main efficacy worry in Stage 1, does not block the shape that was actually reported. The report is also from win32, and there is a test for a CRLF split across chunks, which is exactly the detail a Windows-originated bug needs and the kind of thing that usually gets missed. The failure mode is benign in every direction I could trace: each ambiguous branch releases text verbatim, so the worst realistic regression is a stray tag surviving, which is today's behaviour. A change that can only under-delete is a much easier thing to be confident about than one that can silently eat output.

I traced the interactions that could have gone wrong and none did — the cross-channel rejection still throws, truncation still preserves bytes, cancellation is not laundered into a completion, the #6854 candidate machinery operates on a disjoint phase, and the lazy filter init is safe because RequestContext is fresh per attempt.

The reservation keeping this off 5/5: any answer containing a single backtick keeps its trailing tag, and a coding assistant emits backticks constantly. So this closes the reported instance and the prose-only class, not the family. That's a deliberate, documented scope decision and a defensible one — but it means #2596 should stay open past this merge to track the code-bearing shape, rather than being closed by it. Worth a maintainer's explicit agreement, since "Fixes #2596" in the description will auto-close it.

The nits, none blocking: the two pipeline flush sites answer the leak-ordering question differently (one delivers the tail before a PROTOCOL_TAG_LEAK throw, one deliberately doesn't); the two ~17-line flush blocks and the thrice-repeated completed expression are collapsible into locals; and the new RequestContext field is the only undocumented one in a block whose siblings carry an explicit do-not-reuse warning that applies equally to it.

Not approving in this run. The unit suite carrying the 112 new test lines landed green while this review was in progress, so that gap is closed and there is now real CI evidence behind the new cases. Two PR CI workflow runs are still in flight — SDK Java, and Qwen Code CI with web-shell E2E Smoke queued and Hosted process fault gates running — so approval is deferred until CI lands green on the reviewed commit; if anything goes red or the head moves, it is withheld rather than posted. /tmux and /verify are both still skipped, and the flicker-free half of the claim is not observable from unit tests at all — a maintainer who wants that settled before merging should trigger one. main requires two approvals, so a human vote is needed regardless of what this gate does.

中文说明

Confidence: 4/5 —— 实现扎实且处处保守;我发现的三点都是 nit,唯一的实质性保留是关于本 PR 能覆盖 #2596 的多少,而不是代码是否正确。

回到我看 diff 之前写的方案:在可见内容 converter 中暂存一小段尾部候选,再依据 finish reason 决定,因为改为在最终文本上清理会先画出标签再抹掉。本 PR 正是这么做的,与我的基线一致,包括选择了更难但正确的那个方案而不是更简单的闪烁方案——所以我没有一条明显更简的路径可以主张。

让我从 3 分提到 4 分的,是去核对原始报告、而不是采信 PR 对它的转述。#2596 中引用的样例是普通正文加一个缩进的 <think> ,通篇没有反引号——因此我在 Stage 1 最担心的那个粘滞 literalContent 退出条件,并不会挡住实际被报告的形状。该报告还来自 win32,而 PR 里有一条针对 CRLF 跨 chunk 拆分的测试,这正是 Windows 起源的 bug 所需要的细节,也是最常被漏掉的一类。我能追踪到的每个方向上失败模式都是良性的:每个存疑分支都原样释放文本,所以最现实的回归只是多余标签继续存在——也就是今天的行为。一个「只可能少删」的改动,比一个可能静默吞掉输出的改动容易让人放心得多。

我追踪了可能出问题的交互,都没有出问题——跨通道拒绝仍然抛错,截断仍保留字节,取消没有被伪装成正常完成,#6854 的候选机制处于互斥阶段,惰性的 filter 初始化也安全,因为 RequestContext 每次尝试都是全新的。

让它停在 4 分而非 5 分的保留意见:只要回答中出现一个反引号,尾部标签就会被保留,而编程助手几乎必然输出反引号。所以本 PR 关闭的是被报告的那个实例和纯正文这一类,不是整个家族。这是一个有意为之、且已写入文档的范围决定,也站得住脚——但这意味着 #2596 应在本次合并后继续保持开放,用于跟进含代码的形状,而不应被本 PR 关闭。这一点值得维护者明确同意,因为描述里的「Fixes #2596」会自动关闭它。

nit(均不阻断):pipeline 两个释放点对「泄露时是否交付」给出了不同答案(一个在 PROTOCOL_TAG_LEAK 抛错前交付尾部文本,一个刻意不交付);两个约 17 行的释放块与重复三次的 completed 表达式都可以收成局部变量;新增的 RequestContext 字段是该区块中唯一没有注释的,而它的兄弟字段带有明确的「不得复用」警告,且该警告对它同样适用。

本次运行不批准。 承载 112 行新测试的单测套件在本次审查期间跑完且为绿色,所以那个缺口已经补上,新增用例现在有了真实的 CI 证据。仍有两个 PR CI workflow 在跑——SDK Java,以及 Qwen Code CI(其中 web-shell E2E Smoke 排队中、Hosted process fault gates 运行中)——因此批准推迟到 CI 在被审提交上全绿之后;若有检查变红或 head 发生移动,则不予批准、也不会补发。/tmux 与 /verify 目前都还是 skipped,而「无闪烁」这一半声明在单测中完全无法观测——希望在合并前确认这一点的维护者可以触发其中之一。main 要求两个批准,因此无论本门禁如何判定,都还需要人工投票。

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

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

@qwen-code-review-bot qwen-code-review-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. ✅

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: step 4 verification — the verifier launch was refused once the review reached its compose reserve floor, so no finding in this review was adjudicated by a second agent.

Not reviewed: adversarial persona 6b (3 AM oncall) — the agent stalled on all 3 attempts across two launches, so this lens produced no report.

Not reviewed: counter-frame audit 6d — the agent's response was rejected twice for leaking thinking tags, so the author's frame was never countered.

Not reviewed: test-efficacy probe — qwen review test-efficacy returned harnessValidated:null with both probes inconclusive, so no mutant or hunk-necessity claim was executed.

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

Not reviewed: verification — its prompt was built, but no agent was launched with it, so the posted findings cannot be counted as verified.

⚠️ 12 finding(s) still carried the — [unverified] tag when the loop ended — the verifier never ruled on them, and they are not confirmed.

中文说明

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

未审查(原文为英文):step 4 verification — the verifier launch was refused once the review reached its compose reserve floor, so no finding in this review was adjudicated by a second agent.

未审查(原文为英文):adversarial persona 6b (3 AM oncall) — the agent stalled on all 3 attempts across two launches, so this lens produced no report.

未审查(原文为英文):counter-frame audit 6d — the agent's response was rejected twice for leaking thinking tags, so the author's frame was never countered.

未审查(原文为英文):test-efficacy probe — qwen review test-efficacy returned harnessValidated:null with both probes inconclusive, so no mutant or hunk-necessity claim was executed.

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

未审查:验证——它的 prompt 已构建,但没有 agent 用它启动,发布的发现不能算作已验证。

⚠️ 循环结束时仍有 12 条发现带着 — [unverified] 标记——验证者从未对它们作出裁决,它们不算已确认。

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

Comment thread packages/core/src/core/openaiContentGenerator/trailing-thinking-tag-filter.ts Outdated
Comment thread packages/core/src/core/openaiContentGenerator/converter.ts
Comment thread packages/core/src/core/openaiContentGenerator/pipeline.ts Outdated
Comment thread packages/core/src/core/openaiContentGenerator/converter.ts Outdated
@yiliang114
yiliang114 enabled auto-merge October 7, 2026 16:33
Narrow the filter and close three gaps around it:

- A blank line followed by four spaces or a tab starts a CommonMark indented
  code block, so a closing tag there is literal sample text. The blank line
  leaves with the emitted prefix, so remember it across chunks. A single
  newline plus indent stays a lazy paragraph continuation and is still
  stripped.
- A closing tag that is not the trailing candidate means the answer is about
  the tag itself, so a later identical one is literal too.
- The pending-length cap only bounds a still-open stream. On the final call
  the whole tail is known, so a whitespace-padded tag is still caught.
- "Finished normally" now comes from the same finish-reason mapper that stamps
  the candidate, so tool_calls, function_call, an absent reason and any
  gateway casing suppress the tag instead of releasing it.
- Both pipeline trailing-tag flushes go through one guarded helper, so the
  normal-end path can no longer deliver a cross-channel leak as clean prose
  while the error path suppresses it.
- Collocated test file for the filter, as AGENTS.md requires.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuy9e05gco

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

COMMENT at head efc935fa07be00720e088a694d6ebb83254550ee. Not approving — one Critical stands at this head. I re-derived it from the source myself rather than inheriting the filing review's reasoning, and my own trace reproduces its witness.

R1-1 confirmed: the filter silently deletes legitimate literal content

TrailingThinkingTagFilter decides whether the trailing </thinking> it is about to strip sits in literal context using a hand-listed marker set:

this.literalContent ||= /`|~{3}|<think(?:ing)?(?:\s|>)/i.test(markers);

Three markers: a backtick, ~~~, and an opening <think / <thinking followed by whitespace or >. A closing tag is never itself a literal marker — </thinking> does not match <think(?:ing)?(?:\s|>), because the character after < is /.

Meanwhile the tag it hunts allows unbounded indentation:

const CLOSING_TAG_LINE = /\r?\n[ \t]*<\/(?:think|thinking)[ \t]*>[ \t\r\n]*$/i;

[ \t]* reaches any indent depth, which includes CommonMark indented code blocks — the ordinary way a model shows a sample of markup. So the one form of literal context most likely to contain a closing tag is the one form the marker set cannot see.

I traced the filed witness through the code at this head, single parse(text, final=true, completed=true) call with 'Close the block like this:\n\n- item\n\n </thinking>\n':

  1. literalContent — the text contains no backtick, no ~~~, and </thinking> fails the opening-tag alternative, so it stays false.
  2. CLOSING_TAG_LINE.exec(pending) matches the final \n + six spaces + </thinking> + trailing \n, so candidateStart = closing.index, pointing at the newline that ends the blank line after - item.
  3. prefix = pending.slice(0, candidateStart) = 'Close the block like this:\n\n- item\n'.
  4. eligible — !literalContent is true; /\S/.test(prefix) is true; candidateStart >= 0; and the tail is ~19 chars, well under MAX_PENDING_LENGTH. All four conjuncts hold.
  5. eligible && (!final || (completed && closing)) is true, so the branch returns prefix and sets this.pending = ''.

The indented sample line is gone and the answer ends mid-instruction at - item. That reproduces the witness exactly.

The deletion is silent and not display-only. The class imports nothing and logs nothing; there is no counterpart to the sibling strip path's requestContext.protocolTagSanitized record or logProtocolTagSanitized emission. Because the filter runs inside the converter, the stripped bytes never enter the GenerateContentResponse, so the loss is not cosmetic — it is absent from the recorded turn and therefore from conversation history replayed on the next request, from --output-format json, from ACP and IDE clients, and from transcript export. Nothing downstream can reconstruct it.

Blast radius is not one model. I confirmed the gate is unconditional: provider/default.ts:270-279 returns contentOnlyThinkingTagLeaks: true from getResponseParsingOptions for every model — the model && isQwen3Model(model) branch only adds taggedThinkingTagsAfterReasoning, it does not scope the leak flag. converter.ts:1247-1249 instantiates the filter whenever that flag is set, so every model served through the default OpenAI-compatible provider runs this path. The filing review also lists Cerebras, DashScope, DeepSeek, Fireworks, MiMo, Mistral, ModelScope, Z.ai and user-configured base URLs as inheriting it; I verified the unconditional default-provider flag rather than each inheritor.

This also runs against the contract the PR states on the linked issue — that code-bearing answers preserve their bytes.

Direction for a fix

The narrowing has to make the literal model cover the forms that actually carry a closing tag, not add more markers to a hand-list. At minimum: treat indentation of four or more spaces (and a fenced block opened anywhere earlier in the answer) as literal context, and treat a closing tag as its own marker when the answer's payload is markup — the "how do I close this block" question. Since the failure mode is silent data loss, the strip should also become observable the way the sibling path already is, so a wrong deletion is diagnosable from the record rather than only from a user noticing a truncated answer. Where the context is genuinely ambiguous, keeping the bytes is the safe default and matches what the file's own comment says it intends — "Keep those answers verbatim rather than guessing which occurrence was intentional."

The other four findings

R1-2 through R1-5 are sev:"S" in the same ledger. Under this channel's Critical-only policy I read them and am not tracking or verifying them; they do not affect this verdict.

CI

Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox) and web-shell E2E Smoke all pass at this head, which is consistent with the finding: the witness input is a shape the new tests do not cover, so a green suite does not speak to it. review-pr failed after 3h00m40s, which reads as a timeout in the review job itself rather than a product failure, and under this channel's rules that lane is summarized and never a gate. No CI evidence attributes a defect to this PR beyond the finding above.

Keep blank-separated indented code, raw-text HTML and earlier closing-tag examples intact. Report orphan thinking tag sanitation through the existing event carrier.

Co-authored-by: Qwen-Coder <[email protected]>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Literal preservation follow-up — b96df97b348f

The previous filter removed structurally literal thinking-tag examples from both output and future history. This additive patch preserves blank-separated indented code, raw-text HTML openings and repeated literal closers, and reports actual orphan sanitation through the existing pipeline event. It retains the existing orphan behavior for a single newline followed by indentation after ordinary prose.

Local checks: 543/543 full converter/pipeline cases passed with retry0; fresh core build/typecheck, targeted lint and diff checks passed. CLI source is unchanged and the existing compiled CLI was reused; the actual loaded fresh core converter/filter bytes are pinned below. This is not a new CLI build claim. The final verified source bytes were committed unchanged and normally pushed to this PR.

Native verification report

Status: VERIFIED_FIXED for the two structural literal cases and orphan control; the complete Critical remains a maintainer boundary.
Method: e2e-interactive, one actual native CLI session under tmux with a controlled localhost OpenAI-compatible provider.
Candidate SHA: b96df97b348f9593423498c44b82a3586a428054.
Binary: Existing packages/cli/dist/index.js, Qwen Code 0.25.0. The CLI source is unchanged from the candidate commit. The CLI entry source hash is 82364fb3cd1d8bdc14a9e0d250d5c5eeef22f843f61f2cb97cb99e93f77ee4f0 and matches that exact commit. Before/after snapshots cover 7,905 source/runtime entries and five helper scripts; all hashes and the candidate SHA are unchanged.
Command (paths/key redacted): QWEN_HOME=<isolated-config> QWEN_RUNTIME_DIR=<isolated-runtime> QWEN_DEBUG_LOG_FILE=1 node --import <readonly-loader-hook> <worktree>/packages/cli/dist/index.js --bare --auth-type openai --openai-base-url http://127.0.0.1:<ephemeral-port>/v1 --openai-api-key <fixture-key> --model literal-native-review --approval-mode yolo --sandbox false --chat-recording true --openai-logging --openai-logging-dir <isolated-api-logs> --json-file <isolated-events> --system-prompt 'Respond using the provided controlled fixture. Do not call tools.'

Observed behavior

Case Emitted text Retained conversation and next-request history Result
Blank-separated indented literal "Pattern to strip:\n\n </thinking>" Exact same bytes PASS
Open HTML pre literal "Use:\n<pre>\n</thinking>" Exact same bytes PASS
Single-newline indented orphan "I need to verify the branch state." Exact same bytes, no trailing tag PASS

The provider emitted one content character per SSE chunk and a separate normal stop chunk. Tmux captures show both visible literal tags and the sanitized orphan. Structured assistant events, the native on-disk conversation record, and a fourth history-followup request independently contain the expected strings. Logging was enabled; the existing OPENAI_PIPELINE warning recorded Sanitized a model protocol tag, tagName: think, and toolCallCount: 0 for the orphan.

Runtime provenance

The readonly Node loader recorded the exact modules loaded by the CLI. Each recorded loaded-module hash matches both runtime manifests. The live converter was packages/core/dist/src/core/openaiContentGenerator/converter.js with SHA256 55dd418b3da7d2681a2ecd60c695fc4e50257017a7500aaf01430a23856ef112; the live filter was packages/core/dist/src/core/openaiContentGenerator/trailing-thinking-tag-filter.js with SHA256 d53b5eb3a5578e5b25f2b5ca07ef0a29d86e97f9b6d091355b1311277fdee57d. The live pipeline SHA256 was eec6f82ad111863fc0c6713213b49ab61ff299eef29686229777dae4afe6ba05. Full CLI and module provenance is recorded in public-evidence.json and loaded-modules.jsonl.

Limits and setup

Three main cases ran once each, with zero retries, plus one history-followup turn. The CLI independently made three automatic suggestion requests against the same local fixture; their requests contained the SUGGESTION MODE prompt, and they were not retried or treated as acceptance cases. Seven total controlled-provider requests occurred. The fixture's generic auxiliary reply appears in the final suggestion footer; it is not part of assistant output or retained conversation. The first evidence-summary parser assumed every request had a main-case marker and stopped on an auxiliary request; the summary parser was corrected without rerunning the CLI. Native CLI setup and all tested turns completed on the first attempt.

This run establishes controlled-provider native CLI behavior and durable session history. It provides no real-model evidence and makes no SDK/daemon negotiation claim. An ambiguous lone closer after arbitrary prose remains a maintainer decision tracked by #10559; this bounded verification does not resolve the full Critical.

Cleanup

The tmux session exited and the local provider terminated. Temporary scripts, isolated configuration, workspace, runtime working directory, and API-log working directory were removed after preserving evidence. No user configuration or tracked source changed. Raw local output remains in the native evidence folder; this report, public-evidence.json, tmux-final-redacted.txt, and sanitation-log-redacted.txt omit user/internal paths, runtime IDs, and credentials.

中文验证说明

受控原生 CLI 验证通过: 空行后缩进代码和未闭合 <pre> 中的 </thinking> 均完整保留;单换行后缩进的孤立 </think> 仍清理为预期正文。tmux 画面、结构化 assistant 事件、真实会话落盘记录和下一轮请求历史都确认相同结果。既有流水线日志记录了 Sanitized a model protocol tag,标签为 think,工具调用数为 0。

本轮仅启动一次实际 CLI,会话中运行三个验收场景和一次历史核验,均未重试;另外三次请求来自 CLI 自动建议功能,已从验收场景中区分。实际加载的转换器、过滤器和流水线哈希与候选构建一致,前后 7,905 项源码/运行时文件及五个辅助脚本哈希未变。

这是本地受控服务证据,不是真实模型证据,也未覆盖 SDK/daemon 协商。任意正文后的孤立闭合标签仍属于 #10559 的维护者边界,因此不能据此宣称整个 Critical 已解决。tmux、本地服务和临时配置/运行目录已清理,脱敏报告及证据已保存。

Actual redacted tmux capture

The final footer is the automatic suggestion request described above, not assistant output.


   ▄▄▄▄▄▄  ▄▄     ▄▄ ▄▄▄▄▄▄▄ ▄▄▄    ▄▄   ┌──────────────────────────────────────────────────────────┐
  ██╔═══██╗██║    ██║██╔════╝████╗  ██║  │ >_ Qwen Code (v0.25.0)                                   │
  ██║   ██║██║ █╗ ██║█████╗  ██╔██╗ ██║  │                                                          │
  ██║▄▄ ██║██║███╗██║██╔══╝  ██║╚██╗██║  │ API Key | literal-native-review (/model to change)       │
  ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║  │ <isolated verification workspace>                       │
   ╚══▀▀═╝  ╚══╝╚══╝ ╚══════╝╚═╝  ╚═══╝  └──────────────────────────────────────────────────────────┘

  Tips: Add a QWEN.md file to give Qwen Code persistent project context.

  > CLI_CASE_INDENTED

  ◆︎ Pattern to strip:

        </thinking>

  > CLI_CASE_PRE

  ◆︎ Use:
    <pre>
    </thinking>

  > CLI_CASE_ORPHAN

  ◆︎ I need to verify the branch state.

  > CLI_CASE_HISTORY

  ◆︎ History boundary reached.

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
* Unexpected fixture caller.
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ➜ workspace · literal-native-review
  YOLO mode (shift + tab to cycle)

中文补充:本次增量已正常推送,543 个 converter/pipeline 用例和 core 构建、类型检查、定向 lint 均通过。CLI 源码未改,本轮复用既有编译 CLI,并直接核对实际加载的最新 core 模块。原生 tmux、会话落盘及下一轮历史验证通过;任意正文后的单独闭标签仍待 #10559 决策,完整评审项保持开放。

yiliang114 and others added 2 commits October 8, 2026 01:46
Both commits sit on efc935f and answer different R1 findings, so keep
both: the pushed head owns literal-context detection (blank-separated
indented code, raw-text HTML, earlier-closer marker) and the
protocolTagSanitized carrier; this branch owns the guarded pipeline flush
helper, completedNormally(), and the pending-length cap relaxation with the
collocated filter test.

Conflicts resolved by taking the pushed head's literal-context machinery
and re-applying only the `(final || ...)` cap term on top of it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuydobo9cu
The trailing-tag filter is gated behind `!requestContext.taggedThinkingParser`,
and that gate is load-bearing rather than accidental: TaggedThinkingParser
owns every tag shape once a stream opens a literal thinking block, including
an orphan closer in text mode. Say so where the gate is, so the next
maintainer does not read the gate as an oversight.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuydobo9cu
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Current-head verification report

Reviewed head: 3179bf197f6f60482dd9d5da82233845f2dc51d7.
Status: The three complete focused suites, the requested controlled-provider native CLI scenario, and the requested closeout checks passed.

Consolidated unit and closeout checks

One core workspace build completed successfully to refresh the existing CLI's core dependency. No install and no repeated build occurred. One actual tmux invocation then ran the complete converter, pipeline, and trailing-filter test files with explicit retry0 and default plus JSON reporters.

Test file Passed Failed Unexecuted
converter.test.ts 271 0 0
pipeline.test.ts 275 0 0
trailing-thinking-tag-filter.test.ts 9 0 0
Total 555 0 0

Unit exit code 0; command duration 3.69s. The complete suites include current-head literal and cross-channel handling, mapped completion reasons, truncation, and cancellation paths. No setup failure, selection failure, or test retry occurred.

The same head also passed core tsc --noEmit, ESLint on the six changed source/test files, Prettier --check on those six files, git diff --check, and a whitespace check of the delivered six-file diff from the prior head. Every exit code was 0. Checks were readonly; no formatting or production edits were made.

Unit command (from core; helper path redacted): node ../../node_modules/vitest/vitest.mjs run src/core/openaiContentGenerator/converter.test.ts src/core/openaiContentGenerator/pipeline.test.ts src/core/openaiContentGenerator/trailing-thinking-tag-filter.test.ts --config <readonly-provenance-config> --retry 0 --coverage.enabled false --reporter default --reporter json

Exact native CLI scenario

The existing repository mock OpenAI HTTP/SSE fixture was reused with a small handler/final-delta specialization; no replacement provider infrastructure was built. The actual existing compiled CLI ran non-interactively and loaded the freshly built worktree core modules.

The fixture sent Answer. as ordinary content. Its final natural stop chunk contained \n</thinking> followed by exactly 200 spaces. Thus the complete content shape was Answer.\n</thinking> plus the requested post-tag padding, and the final delta was 212 characters. The raw fixture chunk log verifies the shape and stop reason.

The CLI's streamed text delta, final assistant content, and successful result were each exactly Answer.. The terminal tag and padding were absent; native CLI exit code was 0. The requested scenario ran once with no retry.

The post-tag padding clarification arrived after an earlier bounded CLI invocation had already passed a different shape: ordinary prose followed by 160 spaces before </think>. That invocation is separately recorded and is not treated as proof of the requested post-tag case. Request attribution is one requested primary call, one earlier primary call, zero independent auxiliary/memory calls, two controlled-provider calls total. No independent memory path was exercised.

Native command (paths/key redacted): QWEN_HOME=<isolated-config> QWEN_RUNTIME_DIR=<isolated-runtime> QWEN_DEBUG_LOG_FILE=1 node --import <readonly-loader-hook> <worktree>/packages/cli/dist/index.js --bare --auth-type openai --openai-base-url http://127.0.0.1:<ephemeral-port>/v1 --openai-api-key <fixture-key> --model literal-native-review --approval-mode yolo --sandbox false --chat-recording true --output-format stream-json --include-partial-messages --max-tool-calls 0 --max-session-turns 1 --system-prompt 'Do not call tools. Use the provided controlled response.' -p CLI_FINAL_PADDING_3179

This confirms a known final stop payload. It does not claim that arbitrarily long tails delivered mid-stream are removed; tails exceeding the open-stream cap can still be released by design. It is controlled-provider native evidence, not a real-model result. Daemon, UI, CPA, SDK negotiation, and full current-head CI were not rerun. The earlier b96df97b348f... native report is historical evidence only.

Source and runtime identity

Head and clean tracked status remained fixed. All source entries stayed unchanged through the single build. After the build, twenty source/runtime/config entries remained identical through both CLI scenarios and closeout checks. Unit-phase seven helper hashes and final-phase eight helper hashes were separately fixed before/after; the deliberate fixture adjustment and added check launcher between phases are recorded rather than presented as unchanged helper bytes.

A readonly Vite hook logged the actual unit source modules; their input hashes equal the disk and candidate snapshots. A readonly Node loader logged the actual native CLI dependency modules; their hashes equal both post-build runtime snapshots. No code was replaced by either hook.

Actual unit source module SHA256
packages/core/src/core/openaiContentGenerator/converter.ts 2123326087457dc62bfd2336f177c4f09c0471b32bc5ecc2fa9f8b06cd4e7276
packages/core/src/core/openaiContentGenerator/pipeline.ts b3a0b9f1f8d3ee5dbfd2625db66fd2ac086b0438addbf00347157c7626955927
packages/core/src/core/openaiContentGenerator/trailing-thinking-tag-filter.ts 0c3eb7d09f852b4cf9a3f2c07797160c6c387b07b80bafe29210d315c6d3ad0c
Actual native core module SHA256
packages/core/dist/src/core/openaiContentGenerator/converter.js 13d4d854bf05764555d341e7f90e5b412648f5d787ce606e35aa44cda670323c
packages/core/dist/src/core/openaiContentGenerator/pipeline.js c350deab37d4f94186385a317539802b264265a5de7a99ddc4ed3e309f86ef9c
packages/core/dist/src/core/openaiContentGenerator/trailing-thinking-tag-filter.js 5ba96cdaf40eacc1632d9e7138446d33d1f4310ded51c32cfd8239dc8d713f37

Full manifests and redacted loader provenance are saved with the report. The compiled CLI source/runtime identity remained unchanged while its actual core imports resolved to these refreshed modules.

Evidence, cleanup, and freeze release

Public-safe evidence includes unit-json-report-redacted.json, unit-terminal-report-redacted.txt, tmux-unit-capture-redacted.txt, tmux-native-final-capture-redacted.txt, tmux-checks-capture-redacted.txt, loaded-provenance-redacted.json, and public-evidence.json. Raw local logs remain separate. Public files omit user paths, host/runtime identifiers, and credentials.

Owned tmux sessions, the controlled fixture process, temporary helper scripts, isolated configuration, and runtime working directories were cleaned after recording results. No tracked source/test, unrelated session state, commit, push, or GitHub write was performed. Production/source freeze is released. No further validation or passive CI waiting is planned by this agent.

Actual tmux capture excerpts

Unit run:

RUN  v3.2.7 <reviewed-worktree>/packages/core

 ✓ src/core/openaiContentGenerator/trailing-thinking-tag-filter.test.ts (9 tests) 2ms
 ✓ src/core/openaiContentGenerator/pipeline.test.ts (275 tests) 241ms
 ✓ src/core/openaiContentGenerator/converter.test.ts (271 tests) 94ms

 Test Files  3 passed (3)
      Tests  555 passed (555)
   Start at  02:44:36
   Duration  3.69s (transform 2.24s, setup 12ms, collect 3.80s, tests 337ms, environment 0ms, prepare 102ms)

Final native text delivery:

{"type":"stream_event","uuid":"<redacted-id>","session_id":"<redacted-id>","parent_tool_use_id":null,"event":{"type":"content_block_delta","index":0,"delt
a":{"type":"text_delta","text":"Answer."}}}
{"type":"stream_event","uuid":"<redacted-id>","session_id":"<redacted-id>","parent_tool_use_id":null,"event":{"type":"content_block_stop","index":0}}
{"type":"assistant","uuid":"<redacted-id>","session_id":"<redacted-id>","parent_tool_use_id":null,"message":{"id":"<redacted-id>","
type":"message","role":"assistant","model":"literal-native-review","content":[{"type":"text","text":"Answer."}],"stop_reason":null,"usage":{"input_tokens":3752,"output_tokens":2,"cache_read_input_toke
ns":0,"total_tokens":3754}}}
{"type":"stream_event","uuid":"<redacted-id>","session_id":"<redacted-id>","parent_tool_use_id":null,"event":{"type":"message_stop"}}
{"type":"result","subtype":"success","uuid":"<redacted-id>","session_id":"<redacted-id>","is_error":false,"duration_ms":158,"duration_api_ms":149,"num_tur
ns":1,"result":"Answer.","usage":{"input_tokens":3752,"output_tokens":2,"cache_read_input_tokens":0,"total_tokens":3754},"permission_denials":[]}

中文验证说明

精确 head 3179bf197f6f60482dd9d5da82233845f2dc51d7 验证通过。 仅做一次 core build,随后一次集中跑完整 converter/pipeline/filter 三文件,555 passed、0 failed、0 未执行、retry0、退出码 0。当前 head 的 literal、跨通道、结束原因映射、truncation 和 cancellation 由这三个完整文件覆盖。core typecheck、六文件 ESLint/Prettier --check 以及工作区/交付 diff 空白检查均为 0;未格式化或改生产代码。

最终指定的真实非交互 CLI 场景首次通过:先发送 Answer.,最后自然 stop delta 为换行、</thinking> 加 200 个 tag 后空格;整体内容形状与补充要求一致。流式正文、assistant 和成功 result 均仅为 Answer.,tag 与 padding 没有交付,退出码 0。补充到达前完成的 tag 前 160 空格场景单列,不能替代这一证据。实际请求分别为最终主场景 1 次、较早主场景 1 次、独立辅助/memory 0 次,未验证独立 memory 路径。

这只确认已知最终 stop payload,不承诺任意超过 cap 的中途长尾都会过滤。它是受控 localhost provider 下的原生 CLI 证据,不是真实模型验收;未重跑 daemon/UI/CPA 或新 head 全 CI,旧 b96 报告仅为历史。

一次构建后,二十项源码/运行时/config 哈希、head 和干净工作树状态保持固定;实际加载的源模块及 core 产物哈希与候选快照一致。临时资源已清理,公开报告/capture 已脱敏,生产源码与产物冻结已释放,不再增加验证或等待其他设施。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

The current-head code-validation workflows are completed/success at 3179bf197f6f60482dd9d5da82233845f2dc51d7: Qwen Code CI, SDK Java. The snapshot includes passing Ubuntu unit/lint, WebShell smoke, Java jobs, real-daemon E2E and hosted-process fault gates. The cancelled Autofix route jobs are separate from these completed code-validation workflows.

The 555 local cases, current native CLI scenario and actual tmux captures retain their exact head/runtime attribution. The PR is non-draft and remains open; automatic review and required approvals are separate gates. #10559 remains the family-level policy carrier. No merge, main/release, or family-wide resolution is claimed.

中文:最新 head 的 Qwen Code CI 与 SDK Java 均已完成且成功,555 个本地用例及真实 CLI/tmux 报告已公开。PR 为正式开放状态,自动评审和所需批准仍是独立门槛;#10559 的家族决策保持跟进,不提前认定合并、发布或全家族已修复。

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 21600 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout. See workflow logs.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • the missing test for the flush guard's cancellation conjunct (pipeline.ts:355) — confirmed by mutant (it survives 1133 tests), not drafted as its own comment: its fix-witness ask is carried inside the R1-3 re-report at the same line in this…

Not reviewed: line-by-line correctness (agent 1a) — the agent returned a truncated reasoning fragment rather than a report on both launches, so this lens produced no findings.

Not reviewed: adversarial persona 6a (attacker) — the agent stalled on all attempts across two launches, so this lens produced no report.

Not reviewed: adversarial persona 6c (six-months-later maintainer) — the agent's response was rejected twice for leaking thinking tags, so this lens produced no report.

Not reviewed: test-efficacy probe — qwen review test-efficacy returned harnessValidated: null (the positive control never ran) with all three probes inconclusive, so no mutant or hunk-necessity claim was executed.

Not explored to full depth (tool budget reached): "agent 1d": none — no check was cut short by the tool ceiling..

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

Test Plan (not a blocker): 555 passed — this review observed 35430, 2597, 39460, 1028, 2172, 609, 10901 passed.

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

  • packages/core/src/core/openaiContentGenerator/converter.ts:1560 — [probe] the new reasoning latch rescans the raw delta every chunk — measured +66.8 ms per 100 KB of cumulative reasoning (+56%)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

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

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

未审查(原文为英文):line-by-line correctness (agent 1a) — the agent returned a truncated reasoning fragment rather than a report on both launches, so this lens produced no findings.

未审查(原文为英文):adversarial persona 6a (attacker) — the agent stalled on all attempts across two launches, so this lens produced no report.

未审查(原文为英文):adversarial persona 6c (six-months-later maintainer) — the agent's response was rejected twice for leaking thinking tags, so this lens produced no report.

未审查(原文为英文):test-efficacy probe — qwen review test-efficacy returned harnessValidated: null (the positive control never ran) with all three probes inconclusive, so no mutant or hunk-necessity claim was executed.

未探索到全部深度(达到工具调用预算):"agent 1d":none — no check was cut short by the tool ceiling.。

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

Test Plan(非阻断):555 passed — this review observed 35430, 2597, 39460, 1028, 2172, 609, 10901 passed。

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

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

Comment thread packages/core/src/core/openaiContentGenerator/pipeline.ts Outdated
Comment thread packages/core/src/core/openaiContentGenerator/trailing-thinking-tag-filter.ts Outdated
Comment on lines +25 to +28
this.literalContent ||=
/`|~{3}|<think(?:ing)?(?:\s|>)|<(?:pre|textarea|script|style)(?:\s|>)/i.test(
markers,
);

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] R2-1: The code-context half of this literal test is scoped to the whole response rather than to the construct containing the withheld suffix, and it is one-way — ||= with no reset. A single backtick in an already-closed inline span or fence on line 1 of a long answer permanently disables suppression for a genuine orphan on line 200, which is the ordinary shape of a coding-assistant answer, and provider/default.ts:275 enables this for every model. The marker list also has no last corner: <code>, <samp>, <xmp>, <!-- --> and an unfenced <div> example do not disqualify, so a literal closer inside those is still deleted. And the decline is unobservable — this module imports no logger, while the strip path reaches debugLogger.warn and logProtocolTagSanitized — so an operator answering "why are users still seeing this tag?" cannot distinguish "the filter never saw the shape" from "the filter latched on a backtick in line 3 of a 4000-token answer" without replaying the raw SSE.

Witness (executed at head; UNCHANGED means the trailing orphan survived):

'Config:\n```json\n{"a":1}\n```\nRun `npm test`.\n</thinking>'   oneShot=UNCHANGED  streamed(char-by-char)=UNCHANGED
'Fixed the bug in `converter.ts`.\n</thinking>'                  oneShot=UNCHANGED  streamed=UNCHANGED
'See:\n```sh\nls\n```\nDone.\n</thinking>'                       oneShot=UNCHANGED  streamed=UNCHANGED
'~~~py\nx=1\n~~~\nDone.\n</thinking>'                            oneShot=UNCHANGED  streamed=UNCHANGED
'- use `a`\n- use `b`\nDone.\n</thinking>'                       oneShot=UNCHANGED  streamed=UNCHANGED
'The closing tag is:\n</thinking>'                               oneShot='The closing tag is:'   <- deleted
control 'Answer.\n</thinking>'                                   oneShot='Answer.'

Every fence and inline span in the first five is already closed, so the trailing tag sits in no literal context at all. Scope the code-context half to the construct containing the candidate: track fence open/close and indented-code state as block-level state and disqualify only when the candidate lies inside an open fence, an indented code block or a raw-HTML block, plus an inline code span on the candidate's own line. Keep the two genuinely response-global disqualifiers — the earlier-nonterminal-closer rule at line 79 and the <think-opener rule — as they are, since those are about tag ambiguity rather than code context. Separately, emit one debug log naming the latch reason when final && closing && !eligible because of literalContent. marked's Lexer is already a core dependency and is already used for this same structural-versus-literal question in packages/core/src/core/xml-tool-call-fallback.ts:8. This interacts with R1-1: any change here must also make the earlier-closer disqualifier chunk-independent, or the pinned inline-code row reddens.

Two measured constraints on the fix. const MAX_LEXER_SCAN_LENGTH = 64 * 1024; at packages/core/src/core/xml-tool-call-fallback.ts:23 carries a comment recording that marked's inline lexer is quadratic on unterminated emphasis runs — 3.0s at 8k and 11.7s at 16k, a synchronous stall inside a streaming turn — so any lexer-based variant must bound its input the same way; the block-tracking variant avoids the constraint entirely. And do not simply delete the backtick and tilde-fence alternatives instead of tracking fence state: that mutant reds converter.test.ts's pinned row for an inline-code example (1 failed | 561 passed), because under char-by-char streaming the earlier-closer rule never sees a whole closer in prefix, so that row is preserved by the backtick latch and not by line 79. The same mutant confirms the other half of the point — 'Here:\n```xml\n</thinking>' becomes 'Here:\n```xml', losing a line inside a still-open fence.

Please add expect(run(['See:\n```sh\nls\n```\nDone.\n</thinking>'])).toBe('See:\n```sh\nls\n```\nDone.') to trailing-thinking-tag-filter.test.ts — it is red today and reds again if the scoping reverts to response-global — and confirm the existing carries a literal marker across chunk boundaries case stays green, since it pins that a tag inside a still-open fence is preserved. Then remove the new block-state guard and confirm the added case reddens.

中文说明

[Suggestion] 这段字面上下文判定的「代码上下文」一半是以整个响应为范围的,而不是以包含被扣留后缀的那个结构为范围,并且它是单向的(||=,从不复位)。一篇长回答第 1 行里一个已经闭合的行内代码或围栏中的反引号,就会永久关闭第 200 行真实孤立标签的清理——而这正是编程助手回答的常见形状,provider/default.ts:275 又对每个模型都启用了它。标记列表也没有最后一个角落:<code>、<samp>、<xmp>、<!-- --> 以及未加围栏的 <div> 示例都不会触发保留,因此其中的字面闭标签仍会被删除。此外「拒绝清理」这一路径不可观测——本模块不引入任何 logger,而清理路径会走到 debugLogger.warn 与 logProtocolTagSanitized——于是运维在回答「为什么用户仍然看到这个标签」时,无法区分「过滤器根本没见到该形状」与「过滤器在第 3 行的一个反引号上置起了 latch」,除非回放原始 SSE。

上述见证在 head 上实测:前五种输入的围栏与行内代码都已闭合,末尾标签根本不处于任何字面上下文中,却原样保留;反向的一面是 'The closing tag is:\n</thinking>' 被删成 'The closing tag is:'。建议把代码上下文判定收窄到候选所在的结构:以块级状态跟踪围栏的开/闭与缩进代码状态,仅当候选位于未闭合围栏、缩进代码块或 raw-HTML 块内、或候选自身所在行有行内代码时才判定为字面;把真正需要全响应范围的两条(第 79 行的更早非终止闭标签规则、<think 开始标签规则)保持不变,因为它们讲的是标签歧义而非代码上下文。另外,当因 literalContent 导致 final && closing && !eligible 时输出一条 debug 日志说明原因。marked 的 Lexer 已是 core 依赖,并且 packages/core/src/core/xml-tool-call-fallback.ts:8 已用它处理同样的「结构 vs 字面」问题。本条与 R1-1 相互影响:此处的任何修改都必须同时让「更早闭标签」判定与分片方式无关,否则被固定的行内代码用例会变红。

两条实测约束:packages/core/src/core/xml-tool-call-fallback.ts:23 的 const MAX_LEXER_SCAN_LENGTH = 64 * 1024; 附有注释,记录 marked 的行内 lexer 在未闭合 emphasis 上是二次复杂度(8k 时 3.0s、16k 时 11.7s,属于流式回合内的同步阻塞),因此任何基于 lexer 的方案都必须同样限制输入长度,而块状态跟踪方案完全不受此约束。另外,不要直接删掉反引号与 ~~~ 两个分支而不改为跟踪围栏状态:该变异会让 converter.test.ts 中固定行内代码示例的那一行变红(1 failed | 561 passed),因为在逐字符流式下「更早闭标签」规则永远不会在 prefix 中看到完整的闭标签——那一行是靠反引号 latch 保住的,而不是靠第 79 行。同一变异也印证了另一半:'Here:\n```xml\n</thinking>' 会变成 'Here:\n```xml',在未闭合围栏内丢掉一行。

请在 trailing-thinking-tag-filter.test.ts 中增加 expect(run(['See:\n```sh\nls\n```\nDone.\n</thinking>'])).toBe('See:\n```sh\nls\n```\nDone.')——它今天是红的,若把判定范围改回全响应也会再次变红;同时确认既有的 carries a literal marker across chunk boundaries 保持绿(它固定的是未闭合围栏内的标签被保留)。随后移除新增的块状态判定,确认该用例变红。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reproduced, not fixed — leaving this thread open for a maintainer decision. It hits two of the gates this round works under: a design decision about what the filter is allowed to delete, and an expansion past the contract the PR body states.

Your witnesses all reproduce at head 7df8f5c4322ea63a2cd2c054729daee4687a613e, driving the real class directly:

'Config:\n```json\n{"a":1}\n```\nRun `npm test`.\n</thinking>'   oneShot=UNCHANGED  streamed=UNCHANGED
'Fixed the bug in `converter.ts`.\n</thinking>'                  oneShot=UNCHANGED  streamed=UNCHANGED
'See:\n```sh\nls\n```\nDone.\n</thinking>'                       oneShot=UNCHANGED  streamed=UNCHANGED
'~~~py\nx=1\n~~~\nDone.\n</thinking>'                            oneShot=UNCHANGED  streamed=UNCHANGED
'- use `a`\n- use `b`\nDone.\n</thinking>'                       oneShot=UNCHANGED  streamed=UNCHANGED
'The closing tag is:\n</thinking>'                               oneShot='The closing tag is:'
control 'Answer.\n</thinking>'                                   oneShot='Answer.'

One thing the R1-1 fix in 03b8bdae35 changed: equal=true on all seven now, one-shot and char-by-char agree. Your closing constraint — "any change here must also make the earlier-closer disqualifier chunk-independent, or the pinned inline-code row reddens" — is already satisfied, so a rescoping change would no longer have that as a prerequisite.

Why it is not going into this round:

  1. It moves the destructive surface, in the direction this PR has already got wrong twice. The current latch fails safe: a false positive preserves bytes the user was going to see anyway. Scoping the code-context test to the construct containing the candidate converts that into a decision to delete the trailing tag on any answer that contains a closed fence or inline span — which, as you say, is the ordinary shape of a coding-assistant answer. R1-1 round 1 and the structural regression before it were both this filter deleting text it should have kept. Widening the deletion set on the strength of a heuristic block parser is a product-boundary call, and it is the same boundary already parked for a maintainer in [core] Decide the user-visible output boundary for leaked internal tags (family decision: #10692 / #10700 / #10791 / #10797) #10559. The PR body states the current contract explicitly ("Known code-bearing contexts conservatively preserve the response"), so changing it also changes what the PR promises.
  2. The mechanism you propose is a subsystem, not a guard. Block-level fence open/close state, indented-code state, raw-HTML block state and an inline-span test on the candidate's own line, inside a character-at-a-time streaming filter — plus a debugLogger import into a module that is currently a pure string transform. That is a redesign of the literal-context model, and your own note that this is "the second round on the same literal-context model" is the argument for deciding it deliberately rather than inside a repair round.
  3. The two smaller halves do not stand alone. The incomplete-marker-list half (<code>, <samp>, <xmp>, <!-- -->, unfenced <div>) has no reported trigger, and the one entrance you measured for it — the HTML comment — is already preserved at head, by the earlier-closer latch rather than by the marker list. Adding a debugLogger line for the declined strip is worth doing but is diagnostics for a heuristic whose scope is the open question; it should land with the decision, not before it.

What I would ask the maintainer to pick between: (a) keep the conservative response-scoped latch and accept that suppression does not fire on code-bearing answers, recording that in the PR body as a known limitation of the #2596 fix; (b) adopt the block-state rescoping as a separate PR against #10559's decision, with your requested case expect(run(['See:\n```sh\nls\n```\nDone.\n</thinking>'])).toBe('See:\n```sh\nls\n```\nDone.') and the mutation check you specified; or (c) drop the literal-context heuristic entirely and let the family decision in #10559 settle the boundary first. My recommendation is (a) for this PR plus (b) as its own change — the reported artifact in #2596 is a bare tag after plain prose, which the current head suppresses, and the coding-answer shape is a coverage gap rather than a wrong result.

中文说明

已复现,本轮不修——这条线程留给维护者裁决。它同时踩到本轮的两条门:一是「过滤器允许删除什么」属于设计决策,二是超出了 PR 正文自己声明的契约。

你的七个见证在 head 7df8f5c4322ea63a2cd2c054729daee4687a613e 上全部复现(直接驱动真实类)。有一点与上一轮不同:03b8bdae35 修完 R1-1 之后七例的 equal 全为 true,即单次调用与逐字符流式结果一致,所以你提出的前置约束「此处的任何改动都必须同时让更早闭标签判定与分片方式无关」已经满足。

不放进本轮的三条理由:

  1. 它移动的是破坏性表面,而且方向正是本 PR 已经错过两次的方向。 当前 latch 是安全失效:误判只会保留用户本来就能看到的字节。把代码上下文判定收窄到候选所在的结构,等于把它变成「只要回答里有一个已闭合的围栏或行内代码就删除末尾标签」的决定——而这正如你所说,是编程助手回答的常见形状。R1-1 第一轮与之前的结构化回归,都是这个过滤器删掉了本该保留的文本。仅凭一个启发式块解析器就扩大删除集,属于产品边界裁决,而且正是已经停在 [core] Decide the user-visible output boundary for leaked internal tags (family decision: #10692 / #10700 / #10791 / #10797) #10559 等维护者决定的那条边界。PR 正文也明确写了当前契约(「已知含代码上下文时保守保留整段响应」),改它就等于改 PR 的承诺。
  2. 你提议的机制是一个子系统,不是一个 guard。 块级围栏开/闭状态、缩进代码状态、raw-HTML 块状态、候选自身行的行内代码判定,全部要塞进一个逐字符的流式过滤器;还要在一个目前是纯字符串变换的模块里引入 debugLogger。这是对「字面上下文模型」的重做,而你自己那句「这已经是同一个模型上的第二轮」正说明它应当被慎重决定,而不是在修复轮里顺带做掉。
  3. 两个较小的部分单独站不住。 标记列表不全那半(<code>、<samp>、<xmp>、<!-- -->、未加围栏的 <div>)没有已报告的触发案例,而你为它测到的唯一入口——HTML 注释——在当前 head 上已经被保留,靠的是更早闭标签 latch 而非标记列表。为「拒绝清理」加一行 debug 日志值得做,但它是给一个「作用范围本身就是待决问题」的启发式加可观测性,应当与裁决一起落地,而不是先于裁决。

请维护者在三个选项里挑:(a) 保留保守的全响应 latch,接受含代码的回答不会触发清理,并在 PR 正文里把它记为 #2596 修复的已知局限;(b) 把块状态收窄作为独立 PR、对着 #10559 的裁决来做,并带上你指定的用例与变异验证;(c) 干脆去掉字面上下文启发式,先让 #10559 把边界定下来。我的建议是本 PR 走 (a)、(b) 另开——#2596 报告的异常形状是纯正文后的一个孤立标签,当前 head 已经能清理,编程回答那一类是覆盖面缺口而不是错误结果。

Comment thread packages/core/src/core/openaiContentGenerator/converter.ts
Comment thread packages/core/src/core/openaiContentGenerator/pipeline.test.ts Outdated
Comment thread packages/core/src/core/openaiContentGenerator/converter.ts
The trailing-tag filter decided a repeated closing tag was ambiguous by
testing only the current call's `prefix`. When the earlier closer was
released at `candidateStart === 0`, `prefix` was the empty string, so the
latch never armed while the pass-through branch still armed
`hasVisibleText` from the whole released result. Identical model bytes then
kept or lost their final closing tag purely on where the provider cut the
SSE deltas, and the loss was certified as intentional by
`ProtocolTagSanitizedEvent` plus a `debugLogger.warn`.

Arm the latch from a cumulative view instead: keep a rolling tail of
released text, the way `markerTail` already does for code-fence markers,
and test the closer over that tail plus this call's prefix. The rolling
view is required because a closer can straddle a release boundary
(`"\n<"` then `"/thinking>"`) or be released a character at a time, neither
of which any single call's `prefix` or `result` contains. Withheld
candidates are still never inspected, so the ordinary strip is unaffected.

Exhaustive enumeration of every chunking with up to two cut points now
agrees with the one-shot result on all five ambiguous shapes (1619
chunkings, 0 divergent; was 65/55/105 divergent on the three repeated-
closer shapes). Pinned by three new cases, all of which redden when the
latch reverts to a prefix-only test.

The deliberately untaken witness `'The closing marker is:\n</thinking>'`
stays parked as a maintainer decision in QwenLM#10559 and is not touched here.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuz3e9034c
…mally

`completedNormally`'s reasoning conjunct is redundant on the streaming
path, where `completed && !requestContext.hasThinkingTagInReasoning`
enforces the same rule a second time from a flag latched on the same
chunk. On the non-streaming path it is the only cross-channel guard:
both `hasThinkingTagInReasoning` assignments live inside
`convertOpenAIChunkToLlm`, so `convertOpenAIResponseToLlm` never sets the
flag. The two existing non-streaming cases both pass a `choice()` with no
`reasoning_content`, so nothing discriminated the conjunct and removing it
left the whole `openaiContentGenerator` directory green.

Add the discriminating case: a completion whose reasoning carries a
thinking tag and whose content ends in an orphan closer must keep the
closer verbatim, keep the reasoning as a thought part, and leave
`protocolTagSanitized` unset. Under the mutant that drops the conjunct the
content is stripped to `Answer.` and the sanitization stamp is set, so the
leak is laundered into clean prose instead of staying visible.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuz3e9034c
…nverter cases

Both trailing-tag cases install the real `convertOpenAIChunkToLlm` onto the
module-level `vi.mock` stub and never removed it. The file's only top-level
hook is `beforeEach(() => { vi.clearAllMocks(); ... })`, which clears calls,
instances and results but leaves implementations installed, and neither the
vitest config nor test-setup sets `restoreMocks`/`resetMocks`. From the
moment either case ran, every later test in this 4700-line file inherited
the real converter where the factory default is a bare `vi.fn()` meaning
"returns undefined" — so a later test streaming one chunk more than it
queued `mockReturnValueOnce` responses for would silently run real chunk
conversion, and the same test would behave differently under a full-file
run than under `-t`.

Extract the duplicated 13-line setup into one `withRealChunkConverter`
helper beside `convertChunksTo` and friends, and let it own the restore in
a `finally`. Pin the isolation directly rather than trusting the suite's
green: a case placed after both asserts the stub has no implementation,
which reddens when the restore is dropped (1 failed | 275 passed) and
stays green intact (276 passed).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuz3e9034c
The shared flush guard covered two of the states that make releasing the
trailing-tag filter's withheld tail unsafe, and the normal-end call site sat
above the post-loop leak verdict. Three states reached the consumer as
ordinary model prose on turns that were not safely complete:

- a stream that hands off to TaggedThinkingParser mid-turn never calls the
  filter again, so its `pending` is stranded and the flush released the
  fragment on a turn the same pass then rejects as PROTOCOL_TAG_LEAK;
- visible prose parked in `pendingUntrustedResponseParts` behind a nameless
  tool call is discarded by the error handler, so the catch-path flush left
  a bare closing tag as the turn's only content and flipped `contentYielded`
  on the strength of that fragment;
- a content chunk arriving after the finish response is withheld, so its
  response has zero parts and Stage 2b skips it before the "continued after
  a finish reason" guard can see it; the loop ended with the tail pending and
  the flush released it past the terminal `finishReason`.

Extend the guard inside the function rather than at one call site, so the
normal-end and error-path flushes cannot drift: skip when a finish chunk was
already seen, when prose is parked behind an unattributable tool call, and
when the tagged-thinking parser owns the tags. Move the normal-end flush
below the leak verdict and keep it above the Stage 2d parked-finish yield.
The turn-over condition keys on a finish chunk having been seen, not on the
stream having ended normally, so `preserves pending suffix text without a
normal stop` still delivers its tail.

Safe to withhold because the converter resolves the filter with `final` on
every finish chunk (converter.ts:1609-1627), so anything still held
afterwards arrived past that chunk, and the only tail the filter can hold is
whitespace plus a tag fragment, never prose. Parked parts themselves are
still not delivered, matching the clear before the PROTOCOL_TAG_LEAK throw.

Pinned by five cases, each proven by removing exactly one guard condition:
dropping `finishSeen`, the parked-parts conjunct, the tagged-parser conjunct
or the abort conjunct reddens one test and only that test. The abort conjunct
was previously unpinned -- it survived the whole 275-case file.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuz3e9034c
@yiliang114

Copy link
Copy Markdown
Collaborator Author

This patrol round addressed 4 of the 7 open findings at c6a47ef — both Criticals plus the two mechanical test findings:

  • R1-1 trailing-thinking-tag-filter.ts earlier-closer latch → 03b8bda
  • R1-3 pipeline.ts flush guard and verdict ordering → c6a47ef
  • R2-4 pipeline.test.ts shared-stub isolation → cae9cc9
  • R2-5 converter.ts:1305 non-streaming cross-channel test → e406510

Each thread carries its evidence and mutation result; those four are resolved.

Deliberately left open for the next round — deferred, not declined. None of the three is a correctness regression introduced by the four commits above, and each needs more than a one-line change:

  • R2-1 (filter.ts:28) — the code-context test being scoped to the whole response and one-way needs block-level fence / indented-code state tracking rather than a whole-response ||=; that is a change to the filter's state machine. Not made worse this round, and its stated precondition is now satisfied: the earlier-closer disqualifier is chunk-independent, and the pinned inline-code row is green across all 272 converter tests.
  • R2-2 (converter.ts:1261) — fixing the telemetry stamp properly means first deciding whether it belongs at that layer at all, which also touches the pre-existing writer of the same field at converter.ts:1935-1938. Left alone rather than half-moved.
  • R2-3 (filter.ts:7) — unifying the six definitions of the closing-tag vocabulary spans converter.ts and taggedThinkingParser.ts with a destructive/non-destructive boundary in the middle (one path throws PROTOCOL_TAG_LEAK, the other deletes text). This round kept the count in this file at 3 and only gave the previously anonymous inline regex a name (CLOSING_TAG_INLINE); no new vocabulary was added. The NBSP/form-feed divergence between the detector and the stripper is real and still open.

Verification at c6a47ef: trailing-thinking-tag-filter 12, pipeline 281, converter 272 → 565 passed / 0 failed (baseline before this round was 555); packages/core tsc --noEmit rc=0 with 0 errors; eslint clean on all five changed files; diffstat 5 files, +350/-64. Single non-force push 3179bf197f..c6a47ef26b.

The trailing-tag filter writes requestContext.protocolTagSanitized from
inside convertOpenAITextToParts, which runs before tool calls are parsed,
so it could only hardcode toolCallCount: 0. The pre-existing writer of the
same field fills it from completedToolCalls.length, so the two writers
disagreed about its meaning: every strip this PR adds on a tool_calls
finish was mis-bucketed, telemetry/loggers.ts rendered "preserved 0 tool
call(s)" for a turn that preserved one, and an alert keyed on
tool_call_count > 0 missed that population.

Backfill the count on the finish chunk, where it exists, and pin it with
the file's existing expectSanitized helper — red against the hardcoded 0
and red again if the stamp moves back below the tool-call parse.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuz5jerle5

This branch has not been deployed

No deployments
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.

Qwen CLI keeps adding </think> at the end

5 participants