Repository navigation
fix(core): suppress trailing orphan thinking tags in prose - #13600
yiliang114 wants to merge 10 commits into
Conversation
Co-authored-by: Qwen-Coder <[email protected]>
PR #13600 local verification and native tmux acceptancePASS: the complete ordinary prose is preserved while its terminal orphan Tested head: 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
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.
The following are actual tmux capture blocks with the local terminal prompt sanitized. Full captures are retained in prose: literal: 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 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。普通正文逐字保留,末尾独立成行的 中文补充:测试提交为 |
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
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 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. 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: Approach: scope feels right, and I checked whether the new One genuine question before the code review. The Risk: Stage 1e matches Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 必填小节齐全,Risk & Scope 对「哪些没有验证」写得相当坦诚。 问题: 是已观测到的 bug,不是理论加固。#2596 自三月起开放,多位用户独立反馈普通正文后出现多余的 think 关闭标签,并带有 方向: 对齐,而且我认为 PR 描述低估了自己的覆盖范围。描述里说验收「仅覆盖受支持的 OpenAI-compatible 路径,不代表真实 OAuth」——但最初报告的 OAuth 路径正是走这段代码: 规模: 生产代码 139 行(converter 35、pipeline 39、新增 filter 63、types 2),测试 112 行,生成/schema 0 行。Stage 0 的核心两级门禁在此不适用——作者是维护者——即便适用也不会触发:类型为 方案: 范围合理。我确认过新的 进入代码审查前有一个真正的问题。 风险: Stage 1e 命中 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI 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 No correctness blockers found. Things I traced rather than assumed:
Three things I'd raise, none of them merge blockers. Ordering disagrees between the two flush sites. The catch-path flush sits below the Duplication worth collapsing. The two pipeline flush blocks are ~17 near-identical lines that differ only in their guard, and each hand-builds a The new I checked the new Test evidenceUnattended 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. Final CI results for
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:
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 决定,要么在最终文本上做清理并接受可见闪烁。这里应选无闪烁方案——先画出标签再抹掉,恰恰接近报告者抱怨的现象。因此改动点应是 未发现正确性阻断问题。以下是我实际追踪过、而非默认成立的点:
有三点想提,都不是合并阻断项。 两个释放点的前后顺序不一致。 catch 路径的释放位于 值得合并的重复。 pipeline 中两个释放块约 17 行近乎相同,仅守卫不同,且各自手工构造
我也对照 测试证据无人值守 CI 运行——此处未构建、未执行 PR 代码。以下内容全部来自 GitHub 对被审提交报告的 check-runs。 没有红色检查,因此没有失败日志可摘录。 沙箱验证能补足 CI 无法判定的部分。作者仅在 macOS 测试,而核心声明是行为性的,因此值得点名两条通道——作者具备写权限,两条都不需要 sponsored run:
此处未验证:任何真实 OAuth 或生产 provider 运行(PR 也未作此声明);Windows 与 Linux 行为;以及含代码回答的形状——filter 有意不处理它。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
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 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 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 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 — 中文说明Confidence: 4/5 —— 实现扎实且处处保守;我发现的三点都是 nit,唯一的实质性保留是关于本 PR 能覆盖 #2596 的多少,而不是代码是否正确。 回到我看 diff 之前写的方案:在可见内容 converter 中暂存一小段尾部候选,再依据 finish reason 决定,因为改为在最终文本上清理会先画出标签再抹掉。本 PR 正是这么做的,与我的基线一致,包括选择了更难但正确的那个方案而不是更简单的闪烁方案——所以我没有一条明显更简的路径可以主张。 让我从 3 分提到 4 分的,是去核对原始报告、而不是采信 PR 对它的转述。#2596 中引用的样例是普通正文加一个缩进的 我追踪了可能出问题的交互,都没有出问题——跨通道拒绝仍然抛错,截断仍保留字节,取消没有被伪装成正常完成,#6854 的候选机制处于互斥阶段,惰性的 filter 初始化也安全,因为 让它停在 4 分而非 5 分的保留意见:只要回答中出现一个反引号,尾部标签就会被保留,而编程助手几乎必然输出反引号。所以本 PR 关闭的是被报告的那个实例和纯正文这一类,不是整个家族。这是一个有意为之、且已写入文档的范围决定,也站得住脚——但这意味着 #2596 应在本次合并后继续保持开放,用于跟进含代码的形状,而不应被本 PR 关闭。这一点值得维护者明确同意,因为描述里的「Fixes #2596」会自动关闭它。 nit(均不阻断):pipeline 两个释放点对「泄露时是否交付」给出了不同答案(一个在 本次运行不批准。 承载 112 行新测试的单测套件在本次审查期间跑完且为绿色,所以那个缺口已经补上,新增用例现在有了真实的 CI 证据。仍有两个 PR CI workflow 在跑—— — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
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.
— [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 用它启动,发布的发现不能算作已验证。
— [unverified] 标记——验证者从未对它们作出裁决,它们不算已确认。
— qwen3.8-max via Qwen Code /review (v0.25.0)
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
left a comment
There was a problem hiding this comment.
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':
literalContent— the text contains no backtick, no~~~, and</thinking>fails the opening-tag alternative, so it staysfalse.CLOSING_TAG_LINE.exec(pending)matches the final\n+ six spaces +</thinking>+ trailing\n, socandidateStart = closing.index, pointing at the newline that ends the blank line after- item.prefix = pending.slice(0, candidateStart)='Close the block like this:\n\n- item\n'.eligible—!literalContentis true;/\S/.test(prefix)is true;candidateStart >= 0; and the tail is ~19 chars, well underMAX_PENDING_LENGTH. All four conjuncts hold.eligible && (!final || (completed && closing))is true, so the branch returnsprefixand setsthis.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]>
Literal preservation follow-up —
|
| 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 决策,完整评审项保持开放。
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
Current-head verification reportReviewed head: Consolidated unit and closeout checksOne 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.
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 Unit command (from core; helper path redacted): Exact native CLI scenarioThe 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 The CLI's streamed text delta, final assistant content, and successful result were each exactly 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 Native command (paths/key redacted): 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 Source and runtime identityHead 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.
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 releasePublic-safe evidence includes 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 excerptsUnit run: Final native text delivery: 中文验证说明精确 head 最终指定的真实非交互 CLI 场景首次通过:先发送 Answer.,最后自然 stop delta 为换行、 这只确认已知最终 stop payload,不承诺任意超过 cap 的中途长尾都会过滤。它是受控 localhost provider 下的原生 CLI 证据,不是真实模型验收;未重跑 daemon/UI/CPA 或新 head 全 CI,旧 b96 报告仅为历史。 一次构建后,二十项源码/运行时/config 哈希、head 和干净工作树状态保持固定;实际加载的源模块及 core 产物哈希与候选快照一致。临时资源已清理,公开报告/capture 已脱敏,生产源码与产物冻结已释放,不再增加验证或等待其他设施。 |
|
The current-head code-validation workflows are completed/success at 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 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
left a comment
There was a problem hiding this comment.
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)
| this.literalContent ||= | ||
| /`|~{3}|<think(?:ing)?(?:\s|>)|<(?:pre|textarea|script|style)(?:\s|>)/i.test( | ||
| markers, | ||
| ); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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:
- 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.
- 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
debugLoggerimport 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. - 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 adebugLoggerline 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,即单次调用与逐字符流式结果一致,所以你提出的前置约束「此处的任何改动都必须同时让更早闭标签判定与分片方式无关」已经满足。
不放进本轮的三条理由:
- 它移动的是破坏性表面,而且方向正是本 PR 已经错过两次的方向。 当前 latch 是安全失效:误判只会保留用户本来就能看到的字节。把代码上下文判定收窄到候选所在的结构,等于把它变成「只要回答里有一个已闭合的围栏或行内代码就删除末尾标签」的决定——而这正如你所说,是编程助手回答的常见形状。R1-1 第一轮与之前的结构化回归,都是这个过滤器删掉了本该保留的文本。仅凭一个启发式块解析器就扩大删除集,属于产品边界裁决,而且正是已经停在 [core] Decide the user-visible output boundary for leaked internal tags (family decision: #10692 / #10700 / #10791 / #10797) #10559 等维护者决定的那条边界。PR 正文也明确写了当前契约(「已知含代码上下文时保守保留整段响应」),改它就等于改 PR 的承诺。
- 你提议的机制是一个子系统,不是一个 guard。 块级围栏开/闭状态、缩进代码状态、raw-HTML 块状态、候选自身行的行内代码判定,全部要塞进一个逐字符的流式过滤器;还要在一个目前是纯字符串变换的模块里引入
debugLogger。这是对「字面上下文模型」的重做,而你自己那句「这已经是同一个模型上的第二轮」正说明它应当被慎重决定,而不是在修复轮里顺带做掉。 - 两个较小的部分单独站不住。 标记列表不全那半(
<code>、<samp>、<xmp>、<!-- -->、未加围栏的<div>)没有已报告的触发案例,而你为它测到的唯一入口——HTML 注释——在当前 head 上已经被保留,靠的是更早闭标签 latch 而非标记列表。为「拒绝清理」加一行 debug 日志值得做,但它是给一个「作用范围本身就是待决问题」的启发式加可观测性,应当与裁决一起落地,而不是先于裁决。
请维护者在三个选项里挑:(a) 保留保守的全响应 latch,接受含代码的回答不会触发清理,并在 PR 正文里把它记为 #2596 修复的已知局限;(b) 把块状态收窄作为独立 PR、对着 #10559 的裁决来做,并带上你指定的用例与变异验证;(c) 干脆去掉字面上下文启发式,先让 #10559 把边界定下来。我的建议是本 PR 走 (a)、(b) 另开——#2596 报告的异常形状是纯正文后的一个孤立标签,当前 head 已经能清理,编程回答那一类是覆盖面缺口而不是错误结果。
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
|
This patrol round addressed 4 of the 7 open findings at c6a47ef — both Criticals plus the two mechanical test findings:
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:
Verification at c6a47ef: |
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
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
</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.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 receivedAnswer.followed by a final natural-stop chunk containing\n</thinking>plus 200 trailing spaces, and delivered onlyAnswer.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
efc935fa07beremains 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
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
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 路径存在此形状。后续评审还确认原过滤器删除了结构化代码示例,并污染后续历史,本次修正这些字面示例的保留。
审阅验证计划
</thinking>、未闭合<pre>/<textarea>示例或已出现过的字面闭标签。输出、会话记录及下一轮历史均应保留原始字节。前后证据
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。