Skip to content

fix(core): keep an XML tool call quoted in a parameter value inert - #13515

Merged
yiliang114 merged 2 commits into
mainfrom
fix/issue-13492-quoted-markup-as-data
Oct 6, 2026
Merged

yiliang114 merged 2 commits into
mainfrom
fix/issue-13492-quoted-markup-as-data

Conversation

@yiliang114

@yiliang114 yiliang114 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Stops the XML tool-call fallback from dispatching a call that a parameter value merely quotes. Parameter open and close tags are now paired by depth, and any tool-call match starting inside a span that a closed parameter value owns is treated as that value's data and skipped. The rejected-block rescan is left as it was, so a truncated block that borrowed a following call's closers still has that following call recovered.

Why it's needed

A write_file whose content documented this dialect with an unfenced example call dropped the write and ran the example instead: the quoted shell command was dispatched as a real call. Text a model emits as documentation must not execute. Fenced blocks and explicit example wrappers were already skipped, so the gap was specifically an unfenced quote inside a parameter value — which is exactly what "here is the syntax" prose looks like.

Mechanically: the outer block's lazily matched body ends at the quoted block's closer, so its parameters region holds a call opener the parameters did not consume, and the guard rejects the block — correctly, by its own rule. The rescan added for truncated blocks then restarted just after the outer block's open tag, where the nested complete call matched on its own merits and was returned. At the text level "a following call the truncated block swallowed" and "a call a value quotes" have the same geometry; they differ only in whether a value owns the region, which the flat lazy PARAMETER_PATTERN cannot express because a nested element consumes its enclosing value's own terminator.

Reviewer Test Plan

How to verify

Two tests are added to the existing borrowed closers, lexer cost and rejected-block masking group in packages/core/src/core/xml-tool-call-fallback.test.ts:

  • does not dispatch a call quoted inside a parameter value — the issue's exact input. Before: one recovered call, run_shell_command with command: "rm -rf /tmp/x". After: no calls at all, and tryRecoverXmlToolCalls reports recovered: false with the whole block left in remainingText as prose.
  • still dispatches a real call that follows a value quoting one — pins that the skip is scoped to the value that owns the quoted markup, so a sibling call outside it is still recovered.

The witness the issue calls out, does not dispatch a truncated block that borrows the next call closers, is untouched and still passes: there the swallowing parameter is never closed, so it owns no span and the rescan still reaches the swallowed call. recovers a value that mentions the parameter syntax literally also still passes, for the same reason — an open tag that is never closed owns nothing.

cd packages/core
node ../../node_modules/vitest/vitest.mjs run src/core/xml-tool-call-fallback.test.ts --coverage.enabled=false

Evidence (Before & After)

Before — the reproduction run against the unmodified source on this branch's base:

 FAIL  src/core/xml-tool-call-fallback.test.ts > borrowed closers, lexer cost and rejected-block masking > does not dispatch a call quoted inside a parameter value
- Expected  []
+ Received  [ { "args": { "command": "rm -rf /tmp/x" }, "name": "run_shell_command" } ]

 FAIL  src/core/xml-tool-call-fallback.test.ts > borrowed closers, lexer cost and rejected-block masking > still dispatches a real call that follows a value quoting one
- Expected  [ { name: 'read_file', …(1) } ]
+ Received  [ { …(2) }, …(1) ]   // run_shell_command dispatched ahead of read_file

 Test Files  1 failed (1)
      Tests  2 failed | 80 passed (82)

After — same suite, rebased onto 4ce35fc9c3:

 ✓ src/core/xml-tool-call-fallback.test.ts (82 tests) 176ms

 Test Files  1 passed (1)
      Tests  82 passed (82)

The only consumer of this module is packages/core/src/core/llm-chat.ts, so its suite was run as well: Tests 553 passed (553). npx tsc --noEmit -p tsconfig.json inside packages/core reports no errors, and eslint and prettier both report nothing on the two changed files.

Tested on

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

Environment

Unit tests only (vitest). No runtime, sandbox or network needed.

Risk & Scope

  • Main risk or tradeoff: a call quoted inside a closed parameter value is now never dispatched. If a model ever nested a call inside another call's value and meant it to run, it is now inert — but that reading is indistinguishable from documentation at the text level, and dispatching it is precisely what the issue reports as the bug.
  • Not validated / out of scope: the enclosing block is still rejected, so the write_file in the reproduction is not recovered. It stays visible as prose instead of being replaced by a phantom execution, which is the safe direction, but it is not the pre-hardening behaviour the issue quotes as a comparison. The issue states that rejection is "correctly, by its own guard"; recovering the block would need a value-scoped block-extent rework (re-deriving where the block ends, making the guard and the argument extraction depth-aware, and adding a fallback for values that mention an open tag they never close). That rework also changes the fate of the block pinned by does not let a rejected block parameter swallow a later valid call, so it is deliberately not bundled here.
  • Breaking changes / migration notes: none. No public API change — PARAMETER_PATTERN, TOOL_CALL_PATTERN and both exported functions keep their signatures, and the new helper is module-private.

Linked Issues

Refs #13492

Deliberately not Fixes. This PR closes the execution half of the report: markup that a parameter value merely quotes is no longer dispatched as a real call. The other half the issue reports - the enclosing write_file being dropped - is unchanged: that block is still rejected by its own guard and now stays visible as prose instead of being replaced by a phantom execution. So #13492 should stay open. Recovering the block is the value-scoped block-extent rework described under Risk & Scope; it trades against an existing pinned witness, so it wants its own issue rather than riding along here.

中文说明

这个 PR 做了什么

让 XML tool-call 兜底解析不再把「参数值里引用的示例调用」当成真调用派发。现在会按深度把 parameter 的开标签与闭标签配对,凡是起点落在「某个已闭合参数值所拥有的区间」内的 tool-call 匹配,都视为该值的数据而跳过。被拒块的重扫(rescan)逻辑保持原样,所以「截断块借用了后一个调用的闭合标签」那种情况,后一个调用仍然能被恢复出来。

为什么需要

一个 content 里用未加围栏的示例调用来说明本方言的 write_file,会导致这次写入被丢掉、而示例反而被执行:被引用的 shell 命令被当成真调用派发出去了。模型作为文档输出的文本不应该被执行。围栏代码块和显式 example 包裹此前已经会跳过,所以缺口恰恰是「参数值里未加围栏的引用」——而这正是「语法长这样」这类说明文字的典型形态。

从机制上看:外层块的惰性匹配体会停在被引用块的闭合标签处,于是它的参数区里出现了一个「参数并未消费掉」的调用开标签,守卫据此拒绝该块——按它自己的规则,这个拒绝是对的。而为截断块加上的重扫随后把扫描位置重置到外层块开标签之后,在那里那个完整的嵌套调用凭自身条件被匹配并返回。在文本层面,「截断块吞掉的后一个调用」与「某个值引用的调用」几何形状相同,区别只在于那段区域是否归某个值所有;而扁平且惰性的 PARAMETER_PATTERN 表达不出这个区别,因为嵌套元素会消费掉外层值自己的终止符。

评审测试计划

如何验证

在 packages/core/src/core/xml-tool-call-fallback.test.ts 已有的 borrowed closers, lexer cost and rejected-block masking 分组里新增两个用例:

  • does not dispatch a call quoted inside a parameter value —— issue 里的原始输入。修复前:恢复出一个调用,即 run_shell_command,command: "rm -rf /tmp/x"。修复后:一个调用都不恢复,tryRecoverXmlToolCalls 返回 recovered: false,整块内容作为普通文本留在 remainingText 里。
  • still dispatches a real call that follows a value quoting one —— 钉住「跳过只作用于拥有该引用标记的那个值」:值外面的同级调用仍会被恢复。

issue 点名的那个既有见证用例 does not dispatch a truncated block that borrows the next call closers 未被改动且仍然通过:那种情况下吞掉后一个调用的 parameter 从未闭合,因此不拥有任何区间,重扫仍能到达被吞掉的调用。recovers a value that mentions the parameter syntax literally 同样仍然通过,原因一致——从未闭合的开标签不拥有任何东西。

cd packages/core
node ../../node_modules/vitest/vitest.mjs run src/core/xml-tool-call-fallback.test.ts --coverage.enabled=false

证据(修复前与修复后)

修复前——在本分支基线上、源码未改动时跑复现:

 FAIL  ... > does not dispatch a call quoted inside a parameter value
- Expected  []
+ Received  [ { "args": { "command": "rm -rf /tmp/x" }, "name": "run_shell_command" } ]

 FAIL  ... > still dispatches a real call that follows a value quoting one
- Expected  [ { name: 'read_file', …(1) } ]
+ Received  [ { …(2) }, …(1) ]   // run_shell_command 抢在 read_file 前被派发

 Test Files  1 failed (1)
      Tests  2 failed | 80 passed (82)

修复后——同一套用例,已 rebase 到 4ce35fc9c3:

 ✓ src/core/xml-tool-call-fallback.test.ts (82 tests) 176ms

 Test Files  1 passed (1)
      Tests  82 passed (82)

该模块唯一的使用方是 packages/core/src/core/llm-chat.ts,因此它的用例也一并跑了:Tests 553 passed (553)。在 packages/core 下执行 npx tsc --noEmit -p tsconfig.json 无报错;eslint 与 prettier 对这两个改动文件也都没有意见。

已测试平台

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

环境

仅单元测试(vitest),不需要运行时、沙箱或网络。

风险与范围

  • 主要风险或取舍:落在已闭合参数值内部的调用现在永远不会被派发。如果某个模型真把调用嵌在另一个调用的值里并且期望它执行,那它现在会失效——但在文本层面这种意图与「文档引用」无法区分,而派发它正是 issue 报告的缺陷本身。
  • 未验证 / 范围之外:外层块仍然被拒绝,所以复现里的那个 write_file 没有被恢复。它会以普通文本的形式可见地留下,而不是被一次幽灵执行替换掉——这个方向是安全的,但它不等于 issue 用作对照的加固前行为。issue 明确说该拒绝是「按它自己的守卫,正确地」发生的;要恢复这个块需要一次「按值定界的块范围」重构(重新推导块的结束位置、让守卫与参数提取都具备深度感知,并为「值里提到一个从未闭合的开标签」补一个兜底)。这次重构还会改变 does not let a rejected block parameter swallow a later valid call 所钉住的那个块的命运,因此刻意没有打包进本 PR。
  • 破坏性变更 / 迁移说明:无。公开 API 未变——PARAMETER_PATTERN、TOOL_CALL_PATTERN 与两个导出函数的签名都保持原样,新增的辅助函数是模块内私有。

关联 Issue

Refs #13492

故意不写 Fixes。本 PR 关闭的是报告里的「执行」那半边:参数值只是引用的标记不再被当成真调用派发。issue 同时报告的另一半边——外层 write_file 被丢弃——没有变:那个块仍被它自己的守卫拒绝,现在以普通文本的形式可见地留下,而不是被一次幽灵执行替换掉。所以 #13492 应当保持 open。要恢复这个块属于「风险与范围」里描述的按值定界块范围重构;它与一个既有见证用例存在取舍冲突,应当单开 issue,而不是搭在本 PR 里。

A write_file whose content documented this dialect with an unfenced
example call dispatched the quoted example and dropped the write. The
outer block is rejected correctly - its lazily matched body ends at the
quoted block's closer, so the parameters region holds a call opener the
parameters did not consume - but the rescan added for truncated blocks
then restarted just after the outer open tag, where the nested complete
call matched on its own merits and ran.

Text-level, "a following call the truncated block swallowed" and "a call
a value quotes" have the same geometry; they differ only in whether a
value owns the region, which the flat lazy PARAMETER_PATTERN does not
model because a nested element consumes its enclosing value's own
terminator.

Pair parameter open/close tags by depth and skip any call matched inside
a span a closed value owns. An open tag that is never closed owns
nothing, so the borrowed-closer rescan still reaches a call a truncated
block swallowed, and a value merely mentioning the tag shape still keeps
its enclosing call recoverable.

Fixes #13492

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmuwmsy7g1j
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Oct 6, 2026
@qwen-code-review-bot

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

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval not posted — the PR head moved (or the PR closed) after the review of 76513d8; approving now would attest to unreviewed code. Re-run @qwen-code /triage on the new head. finalize run

⚠️ 延迟审批未提交 —— 审查 76513d8 之后 PR head 已变更(或 PR 已关闭),此时审批会为未审查的代码背书。请在新 head 上重新运行 @qwen-code /triage。查看 finalize 运行

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every section filled in, the Chinese translation is complete rather than abbreviated, and the before/after carries real output.

Problem: observed, not theoretical. #13492 has a runnable reproduction, measured output at three separate commits (2a064ee999, 2ce7b0c144, 838292de11), and it names the commit that introduced the regression. A write_file whose payload documents this dialect ends up executing the quoted rm -rf /tmp/x example while the write the user asked for is dropped. Model documentation turning into a dispatched tool call is about as concrete as this class of bug gets.

Direction: aligned. "Markup a parameter value quotes is that value's data" is the invariant the pre-hardening base already had, so this restores known-good semantics instead of inventing new ones. The deeper fix — re-deriving block extent from value-scoped spans so the enclosing write_file is recovered rather than left inert — is deliberately deferred, and the stated reason (it changes the fate of the block pinned by does not let a rejected block parameter swallow a later valid call) is a good one for keeping it out of a 52-line fix. CHANGELOG: no direct reference, and this fallback parser is qwen-code-specific, so there is nothing to cite either way.

Size: core path (packages/core/src/core/**), so the two-tier gate applies — 52 production lines / 40 test lines / 0 generated or schema, well under every threshold, and the author holds admin on this repo so the maintainer exemption applies regardless. No Tier 1 or large-PR escalation.

Approach: the scope feels right, and it beats the obvious cheaper fix. Just pushing the rescan past the whole rejected block would also make both new tests pass, but it would still dispatch the second example when one value quotes two calls; depth-paired spans cover that, because both quoted calls fall inside the span the closed value owns. That variant is the thing that separates this approach from the naive one, and it is the one case the new tests don't pin — worth adding, raised as a suggestion in the review comment rather than a blocker. No unrelated changes and no drive-by refactor: the helper is module-private and the exported surface is untouched.

Risk: no elevated risk signals — neither changed file matches the revert-correlated path list. One process point rather than a code one: the description says Fixes #13492, which auto-closes an issue whose other half (the dropped write_file) is explicitly out of scope here. Filing the follow-up the description already proposes — or dropping the closing keyword — keeps that half from disappearing when this merges.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 各小节都填了,中文说明是完整翻译而不是摘要,before/after 也给了真实输出。

问题:是已观测到的 bug,不是理论性加固。#13492 提供了可运行的复现、在三个不同 commit(2a064ee999、2ce7b0c144、838292de11)上的实测输出,并点名了引入回归的那次提交。一个 content 里说明本方言的 write_file,结果被引用的 rm -rf /tmp/x 示例真的被执行了,而用户真正要的写入被丢掉。模型写的说明文字变成被派发的工具调用,这类缺陷里算是证据最扎实的一种。

方向:对齐。「参数值引用的标记就是该值的数据」正是加固前基线本来就有的不变量,所以这是在恢复已验证过的语义,而不是发明新语义。更彻底的修法——用按值定界的区间重新推导块范围,让外层 write_file 被恢复而不是保持失效——被刻意推迟了,理由也站得住(它会改变 does not let a rejected block parameter swallow a later valid call 所钉住的那个块的命运),不该塞进一个 52 行的修复里。CHANGELOG:无直接相关条目,且该兜底解析器是 qwen-code 独有的,两边都没有可引用的信号。

规模:触及核心路径(packages/core/src/core/**),因此适用两级门禁——生产代码 52 行 / 测试 40 行 / 生成与 schema 0 行,远低于所有阈值;作者在本仓库有 admin 权限,维护者豁免同样适用。不触发 Tier 1 或大 PR 提示。

方案:范围合理,而且优于那个看起来更省事的修法。仅仅把重扫位置推到整个被拒块之后,也能让两个新用例通过,但当一个值里引用了两个调用时,第二个示例仍会被派发;按深度配对区间能覆盖这种情况,因为两个被引用的调用都落在「已闭合值所拥有的区间」里。这个变体正是本方案与朴素修法的分水岭,也是新用例唯一没有钉住的一种——建议补上,我在评审意见里作为 suggestion 提出,不作为阻塞项。没有无关改动,也没有顺手重构:新增辅助函数是模块内私有,导出面未变。

风险:无升级风险信号——两个改动文件都不匹配与回滚相关的路径清单。有一点属于流程而非代码:描述里写的是 Fixes #13492,合并后会自动关闭这个 issue,而该 issue 的另一半(被丢掉的 write_file)明确不在本 PR 范围内。建议按描述里自己提出的那样单开一个后续 issue,或者去掉关闭关键字,免得那一半随合并一起消失。

进入代码审查 🔍

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

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

@github-actions github-actions Bot removed the review/self-reported The linked issue was opened by the PR author (self-reported) label Oct 6, 2026
@qwen-code-review-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I wrote my own plan before opening the diff: pair parameter open/close tags by depth, then treat any tool-call match starting inside a closed value as that value's data. That is what this does, so the approach matches my independent proposal. The cheaper variant I also considered — pushing the rescan past the whole rejected block instead of past its open tag — is worth naming, because it shows why the extra machinery earns its place: it makes both new tests pass, but when one value quotes two example calls the lazy outer match still ends at the first inner closer, so the second example gets dispatched. Depth-paired spans put both quoted calls inside the span the closed content value owns, and skip both.

No correctness blockers found. The load-bearing question is whether the new skip can ever suppress a call that should run, and the stack discipline answers it: a close tag always pops the most recent open, and a well-formed call's own parameter open tag always sits after its opener. So the span a real call's own parameter creates can never start before that call's opener — a span can only start earlier when the opener is genuinely nested inside a parameter element, which is data by definition. An open tag that never closes owns nothing, which is what keeps the two witnesses the issue names intact. I hand-traced those against the new code rather than taking the description's word: does not dispatch a truncated block that borrows the next call closers (the swallowing content never closes, so the rescan still reaches run_shell_command), recovers a value that mentions the parameter syntax literally (the literal <parameter name="x"> stays on the stack and owns nothing), does not let a rejected block parameter swallow a later valid call, does not dispatch a complete call embedded in a rejected block name, keeps example tags in parameter data from hiding a following real call, recovers the intact call after an envelope whose block never closed, and preserves a parameterless block whose name contains parameter syntax. All seven behave identically after the change, and both new tests produce exactly the asserted output.

Two smaller things I checked because they are easy to get wrong here. The continue on a skipped match cannot spin: TOOL_CALL_PATTERN cannot match empty, so lastIndex has already advanced past the match. And PARAMETER_TAG_PATTERN reuses the tag idiom computeExampleRanges already uses for <example> — the three alternatives are disjoint on their first character, so there is no ambiguous nesting for the engine to backtrack into, and admitting quoted runs whole keeps a > inside an attribute value from ending the tag early, consistent with openTagEnd. The added cost is one linear scan per call plus an O(matches × spans) containment check; the function already does a full-text PARAMETER_PATTERN scan and positionInsideFence already does a parameterRanges.some() per line, so this is not the dominant term and needs no new bound.

For the core-path confidence bar, the consumer set is complete and small: the only production caller is llm-chat.ts (one call site, tryRecoverXmlToolCalls), gated on streamError === null && !hasToolCall && hasFinishReason && contentText && containsXmlToolCalls(contentText) — so it runs once per completed turn and never on a partial stream chunk. The module is not re-exported from packages/core/index.ts, so there is no public API surface, and the only other importer is its own test file. That gate also bounds the residual below.

Suggestions, neither blocking:

  • The two-quoted-calls-in-one-value case is the one that separates this approach from the naive rescan fix, and nothing pins it. Worth a third test in the same group — it is the regression a future simplification would most plausibly reintroduce.
  • A residual the diff does not create and correctly leaves alone: a quoted call inside a value that never closes is still dispatched by the rescan. That is exactly what does not dispatch a truncated block that borrows the next call closers pins as desired, and because the fallback only runs on a completed turn, reaching it needs a turn truncated mid-value (a MAX_TOKENS cutoff, say) that also contains a complete nested call. Fine to leave here; worth one line in the follow-up issue so it is written down somewhere.

Conventions are clean: module-private helper, kebab-case file, no any, no new exports, comments explain the why (the flat-vs-paired distinction) rather than restating the code.

Testing

Evidence this comment carries: a static read of the diff and of the surrounding module in a clean worktree at the reviewed commit, plus the PR's own CI results read through the API. I did not build or run anything from this PR — triage never executes PR-derived code — so nothing below is my own test run.

The decisive check for this change is Test (ubuntu-latest, Node 22.x), which runs xml-tool-call-fallback.test.ts, and it was still in progress when I fetched (single fetch, no polling). Lint & Static was also still running. Nothing on this commit is red: zero failures across 80 check-runs. Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) are skipped by the workflow rather than passing, which lines up with the description's own ⚠️ for those two platforms — it does not worry me for a pure text transform with no platform-dependent code path, but it does mean CI will not cover them either.

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

Check Conclusion
Hosted process fault gates / MySQL 8.4 / Java 21 ❌ failure
Test (ubuntu-latest, Node 22.x) ❌ failure
web-shell E2E Smoke (ubuntu-latest, Node 22.x) 🚫 cancelled
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Flyway migration version uniqueness ✅ 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
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
windows-latest / Java 21 ✅ success

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

Sandboxed verification would settle the one thing CI cannot: @qwen-code /verify — that the two new tests are actually load-bearing. A green suite proves they pass with the diff; nothing in CI proves they fail without it, and the before/after block in the description showing both failing on base (2 failed | 80 passed) is the author's claim, not something I re-ran. That A/B is precisely what /verify produces against the base build. The author has write access here, so it needs no sponsor.

中文说明

代码审查

我在看 diff 之前先写了自己的方案:按深度把 parameter 的开闭标签配对,凡是起点落在已闭合值内部的 tool-call 匹配都当作该值的数据。本 PR 正是这么做的,所以方案与我的独立提议一致。我也考虑过一个更省事的变体——把重扫位置推到整个被拒块之后而不是开标签之后——值得点出来,因为它正好说明这套机制不是多余的:那个变体也能让两个新用例通过,但当一个值里引用了两个示例调用时,外层惰性匹配仍然停在第一个内层闭合标签处,于是第二个示例会被派发出去。按深度配对的区间让两个被引用的调用都落在已闭合 content 值所拥有的区间里,两个都跳过。

未发现正确性阻塞项。关键问题是这个跳过会不会误伤本该执行的调用,栈的配对规则回答了它:闭合标签总是弹出最近的那个开标签,而一个格式完整的调用,它自己的 parameter 开标签必然位于自身开标签之后。所以真实调用自己那个 parameter 所产生的区间,起点不可能早于该调用的开标签——只有当开标签真的嵌套在某个 parameter 元素内部时,区间起点才会更早,而那种情况按定义就是数据。从未闭合的开标签不拥有任何区间,这正是 issue 点名的两个见证用例不受影响的原因。这些我是照着新代码手工推演的,没有直接采信描述:does not dispatch a truncated block that borrows the next call closers(吞掉后一个调用的 content 从未闭合,重扫仍能到达 run_shell_command)、recovers a value that mentions the parameter syntax literally(字面量 <parameter name="x"> 留在栈上、不拥有任何区间)、does not let a rejected block parameter swallow a later valid call、does not dispatch a complete call embedded in a rejected block name、keeps example tags in parameter data from hiding a following real call、recovers the intact call after an envelope whose block never closed、preserves a parameterless block whose name contains parameter syntax。这七个在改动后行为完全一致,两个新用例也恰好得到断言的输出。

另外两处容易写错、我专门看了。跳过时的 continue 不会死循环:TOOL_CALL_PATTERN 不可能匹配空串,所以 lastIndex 已经推进到该匹配之后。PARAMETER_TAG_PATTERN 复用了 computeExampleRanges 里处理 <example> 的同一套标签写法——三个分支的首字符互斥,引擎没有歧义可回溯,而把引号区间整体吞下也让属性值里的 > 不会提前结束标签,与 openTagEnd 的处理一致。新增开销是每次调用一趟线性扫描,加上 O(匹配数 × 区间数) 的包含判断;该函数本来就要对全文跑一遍 PARAMETER_PATTERN,positionInsideFence 本来也要逐行做 parameterRanges.some(),所以这不是主要开销,不需要新的上限。

关于核心路径要求的确定性,使用方集合是完整且很小的:唯一的生产调用方是 llm-chat.ts(一处调用 tryRecoverXmlToolCalls),前置条件是 streamError === null && !hasToolCall && hasFinishReason && contentText && containsXmlToolCalls(contentText)——也就是说它每个已完成的回合只跑一次,绝不会在流式分片上跑。该模块没有从 packages/core/index.ts 再导出,因此不存在公开 API 面;除它自己的测试文件外没有其他导入方。这个前置条件也界定了下面那条残留风险的边界。

两条建议,都不阻塞:

  • 「一个值里引用两个调用」这种情况,正是本方案与朴素重扫修法的分水岭,而现在没有用例钉住它。建议在同一分组里补第三个用例——将来有人做简化时,最可能重新引入的就是这个回归。
  • 一条不是本 diff 造成、也被正确留在原地的残留:位于从未闭合的值内部的被引用调用,仍会被重扫派发出去。这恰好是 does not dispatch a truncated block that borrows the next call closers 钉住的期望行为;而且因为兜底只在已完成回合上运行,要触发它需要回合在值中间被截断(比如 MAX_TOKENS 截断)、同时内部又包含一个完整的嵌套调用。留在这里没问题,但建议在后续 issue 里写一句,免得没人记录。

约定方面干净:辅助函数模块内私有、文件名 kebab-case、无 any、无新增导出,注释解释的是 why(扁平匹配与深度配对的差别)而不是复述代码。

测试

本条意见携带的证据:在干净 worktree 里对所审 commit 的 diff 及其周边模块做静态阅读,加上通过 API 读到的该 PR 自身 CI 结果。我没有构建或运行本 PR 的任何内容——triage 从不执行 PR 派生的代码——所以下面没有一项是我自己跑出来的测试结果。

对这个改动起决定作用的是 Test (ubuntu-latest, Node 22.x),它才会跑 xml-tool-call-fallback.test.ts,而我抓取时它仍在进行中(只抓一次,不轮询)。Lint & Static 同样还在跑。该 commit 上没有红灯:80 个 check-run 里失败数为 0。Test (macos-latest, Node 22.x) 与 Test (windows-latest, Node 22.x) 是被工作流跳过、而不是通过,这与描述里这两个平台自己标的 ⚠️ 一致——对一个不含平台相关代码路径的纯文本转换来说我并不担心,但确实意味着 CI 也不会覆盖它们。

(上方表格中的检查名与结论为 GitHub 元数据,未在此重复。)

有一件事 CI 无法证明,可以用沙箱验证补上:@qwen-code /verify —— 即那两个新用例是否真的「承重」。绿灯只证明它们带着 diff 能通过;CI 里没有任何东西能证明它们不带 diff 会失败,而描述里那段显示两者在基线上失败(2 failed | 80 passed)的 before/after 属于作者的自述,不是我重跑的结果。这个 A/B 正是 /verify 对着基线构建产出的东西。作者在本仓库有写权限,因此不需要担保人。

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

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the fix is right and narrowly scoped; the two things keeping it off 5 are a missing test for the case that justifies the approach, and a closing keyword that will shut an issue this only half-fixes.

Stepping back: this restores an invariant the module used to have, rather than adding a new rule. "Markup a value quotes is that value's data" is what the pre-hardening base did, the rescan broke it, and this puts it back with the minimum machinery that can actually express the distinction. My own cheaper idea would have passed the new tests and still shipped a hole, so the approach exceeds what I proposed — that is the main reason I am comfortable here.

The failure mode also points the right way. Everything this change can newly do is leave text inert and visible; nothing it does can make a call run that would not have. In a module whose whole job is deciding whether model prose is an instruction, that asymmetry matters more than completeness, and it is why the deferred block-extent rework is a reasonable thing to defer rather than an excuse.

My one real reservation is about scope, not correctness. After this change the reproduction in #13492 recovers nothing, where the pre-hardening base at least recovered the write_file with a truncated content. The description is upfront about that and I agree with the tradeoff — silently writing a truncated file is worse than leaving an inert block on screen — but it does mean the issue is half-addressed while the description says Fixes #13492. Merging as written auto-closes it, and the half about the user's lost write disappears from the tracker. The description already proposes the follow-up issue; filing it (or dropping the closing keyword) before merge is the cheap way to keep the thread honest. Non-blocking either way — a maintainer can close that gap in the merge itself.

On the reflection questions I would otherwise skip: the problem is verified, not accepted on framing — I read the reproduction in #13492 and traced it through the base source by hand before opening the diff, and the trace reproduces the reported output. This is not volume wearing me down either; the same author has #13437 merged and #13492 filed in this area, and I judged this one on its reproduction and named regressing commit, not on the narrative around it. Six months from now the helper's docstring is the thing that saves whoever touches this next, and it explains the flat-versus-paired distinction instead of restating the loop.

What I am not confident about, stated plainly: my "no regression" claim rests on hand-tracing seven of the 66 tests in the file plus a unit suite that had not finished when I fetched it. That is decent static evidence and it is not a test result. So the approval is deferred until CI lands green on 76513d8d52b063bd319f9836f73b86dad28c339e rather than posted now — if Test (ubuntu-latest, Node 22.x) comes back red, nothing gets approved and the status comment says so.

中文说明

置信度:4/5 —— 修复方向正确、范围收得很紧;没给到 5 分的两点是:缺一个钉住「本方案存在理由」的用例,以及关闭关键字会把一个只修了一半的 issue 关掉。

退一步看:这是在恢复该模块本来就有的不变量,而不是新增规则。「值所引用的标记就是该值的数据」正是加固前基线的行为,重扫破坏了它,而本 PR 用能真正表达这个区别的最小机制把它恢复了。我自己那个更省事的想法能让新用例通过、却仍会留下漏洞,所以本方案优于我的提议——这也是我在这里感到踏实的主要原因。

失效方向也是对的。这个改动能新造成的后果,只有让文本失效且可见;它不会让任何原本不会执行的调用被执行。在一个职责就是判断「模型散文是不是指令」的模块里,这种不对称比完备性更重要,也正因如此,把「按值定界的块范围」重构推迟下去是合理的取舍,而不是托词。

我唯一实质性的保留是关于范围,而不是正确性。改动之后,#13492 的复现场景什么也恢复不出来;而加固前的基线至少还能恢复出那个 write_file(content 被截断)。描述对此说得很明白,我也认同这个取舍——悄悄写进一个被截断的文件,比在屏幕上留一块失效标记更糟——但这确实意味着该 issue 只被解决了一半,而描述里写的是 Fixes #13492。照此合并会自动关闭它,关于「用户的写入丢失」的那一半就从跟踪里消失了。描述自己已经提出了后续 issue;合并前把它开出来(或去掉关闭关键字)是维持线索诚实的最省事做法。两种处理都不阻塞——维护者在合并时就能补上。

关于我平时容易跳过的几个自省问题:问题是验证过的,不是采信了 PR 的叙述——我在打开 diff 之前先读了 #13492 的复现,并对着基线源码手工推演过,推演结果与报告的输出一致。也不是被数量磨软了:同一作者在这个领域有已合并的 #13437 和已提交的 #13492,而我评判本 PR 依据的是它的复现和被点名的回归提交,不是它周边的叙事。六个月后能救下一个动这块代码的人的,是那个辅助函数的文档注释——它解释的是扁平匹配与深度配对的差别,而不是复述循环。

我不自信的地方也直说:我「无回归」的判断,依据是手工推演了文件里 66 个用例中的 7 个,加上一份我抓取时尚未跑完的单元测试。这算是不错的静态证据,但它不是测试结果。所以批准被推迟到 CI 在 76513d8d52b063bd319f9836f73b86dad28c339e 上全绿之后,而不是现在给出——如果 Test (ubuntu-latest, Node 22.x) 返回红灯,就不会有任何批准产生,状态评论里会写明。

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

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

@yiliang114 yiliang114 left a comment

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.

Reviewed the full diff (base 4ce35fc…head 76513d8) and the complete module at head. No blocking findings; correct as far as I can verify. Evidence below.

How it works. The fix is purely additive: closedParameterSpans pairs parameter tags by depth, and any TOOL_CALL_PATTERN match whose opener starts inside a closed value span is skipped before the guards run. Every remaining branch is base code, so the change can only suppress dispatches, never add one — I confirmed by tracing both added branches and by the red/green runs below.

Verification I ran myself (in a worktree at the head commit, npm run build first):

  • packages/core fallback suite: 82/82 green at head, including the untouched borrowed-closers witness.
  • Red-first reproduced: head test file against the base source yields exactly the two new tests failing and the 80 pre-existing ones passing.
  • eslint (changed files) and tsc --noEmit (packages/core): clean.
  • Boundary probes beyond the added tests (run once, then deleted): <function=name> / <parameter=name> dialect parity; a real sibling call after a closed value still dispatches; an unclosed outer parameter owns nothing (the truncated-block rescan path stays intact); an opener at the exact span-end dispatches; a nested parameter pair inside a value does not reopen it. All 5 pass.

Consumers. Sole production consumer is llm-chat.ts (containsXmlToolCalls gates tryRecoverXmlToolCalls); the fix strictly reduces recovered calls in quoted-markup text, which is the intended security improvement there — no consumer relies on getting more calls. containsXmlToolCalls is unchanged, so the gate/recovery flow is coherent: unsupported matches now leave the text as prose, exactly as the new first test asserts via remainingText.

Tests. Both added tests pass the value gate: the first reproduces the #13492 incident (fails pre-fix, passes post-fix), the second pins that the skip is scoped to the owning value — a broad skip would fail it. The first also asserts the tryRecoverXmlToolCalls contract (remainingText preserved).

Two non-blocking notes: (1) CI was still mid-run on the head commit when I reviewed (ubuntu Test and Lint & Static in progress; macOS/Windows Test skipped by matrix), so merge should wait for green rather than my review. (2) Per the repo's two-tier rule for packages/core this is a small-scope core change (52 production lines); my confidence is high from the evidence above, but maintainer sign-off is still required — treating this comment as the requested gate evaluation, not an approval.

@yiliang114
yiliang114 enabled auto-merge October 6, 2026 13:53

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

Tier: Standard. Pure additive change (92 lines, 0 deleted) to the XML tool-call fallback parser. No public API changes.

Scope. xml-tool-call-fallback.ts (new PARAMETER_TAG_PATTERN regex, closedParameterSpans() helper, guard in recoverableToolCallBlocks) and its test file. Sole production consumer is llm-chat.ts, which is unchanged. Not reviewed: macOS/Windows runtime behavior (PR flags both as untested; unit-only scope).


Findings

No blocking findings.

What was checked

PARAMETER_TAG_PATTERN regex — /<\/?parameter(?=[\s=>])(?:[^>"']|"[^"]*"|'[^']*')*>/g. The (?=[\s=>]) lookahead correctly prevents matches on longer tag names like <parameterize>. Quoted attribute values are handled by the alternation. lastIndex = 0 is reset at closedParameterSpans entry, preventing stale state between calls. Self-closing <parameter/> is not matched (the lookahead expects \s, =, or >); self-closing parameters are not part of this dialect, so this is correct.

closedParameterSpans depth logic — The stack-based pairing is correct: open tags push their start position; close tags pop and record a [start, end) span where end = tagMatch.index + tagMatch[0].length (position just past the > of the close tag). Unmatched open tags stay on the stack and contribute no span, which preserves the existing borrowed-closer rescan path: an outer block whose parameter is never closed does not suppress a swallowed sibling call.

Span check boundary — matchStart >= start && matchStart < end uses an exclusive end, correctly excluding a call that starts immediately after </parameter>. Traced the geometry for both added tests: the inner run_shell_command opener falls inside the content value span in both; the read_file opener in the second test falls after the span end and dispatches normally.

Rescan interaction — When the outer write_file block is rejected and TOOL_CALL_PATTERN.lastIndex is set to resumeAt, the next exec() finds the inner run_shell_command block. Its matchStart is inside the content value span — continue fires. Since exec() already advanced lastIndex past that match, the next iteration searches from after the inner block's end, finds no further <invoke> before the outer block's real close, and returns zero dispatched calls. The sibling read_file in the second test starts outside all value spans and is dispatched correctly.

Tests — Both added tests are effective. The first reproduces the #13492 incident precisely (fails against base, passes at head). The second pins that the skip is scoped to the owning value — a broad guard would fail it. Deleting the valueSpans.some(...) guard makes both red. The five pre-existing borrowed-closer witnesses are untouched and still pass.

Consumer check — llm-chat.ts is the sole production consumer. The fix strictly reduces recovered calls for quoted-markup text. No consumer depends on getting more calls back from the fallback; the change is a security improvement in the safe direction.

Cross-check — yiliang114 reviewed at prior head 76513d8d52b0 (COMMENTED, no approval): no issues flagged, five boundary probes confirmed. My analysis confirms the same conclusions. No misses found.


No blocking findings. Approval blockers: none.

Reviewed with AI assistance.

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

Critical-only scan at head 9cb4847d9e0677f69559238d144877670e4bc5a9 — complete. Verdict: APPROVE.

No Critical found, and there is no historical blocking issue to clear: the PR has zero review threads, no prior review findings from any reviewer, and the one existing review entry is the author's own self-assessment, explicitly graded "no blocking findings" and explicitly not offered as an approval.

The defect being closed is a real execute-from-prose path, not a cosmetic one

Before this change, a write_file whose content documented this dialect with an unfenced example call had the example dispatched. The first new test reproduces the reported incident, and the value that ran was run_shell_command with command: "rm -rf /tmp/x". Text a model emits as documentation executed as a tool call, while the write the user actually asked for stayed behind as prose. That is the worst available direction for a fallback whose entire job is deciding which model-authored markup becomes an action.

The mechanism the body describes checks out against the source. The outer block's lazily matched body ends at the quoted block's closer, so its parameter region holds a call opener the parameters did not consume and the borrowed-closer guard rejects the block — correctly, by its own rule. The truncated-block rescan then restarted just after the outer open tag, where the nested complete call matched on its own merits. Flat lazy PARAMETER_PATTERN cannot distinguish that from a genuinely swallowed following call, because a nested element consumes its enclosing value's own terminator. Pairing by depth supplies the missing fact: whether a value owns the region.

The change is monotonic in the safe direction, which bounds its blast radius

closedParameterSpans feeds exactly one new branch, and that branch only ever continues. It cannot admit a match the guards would have rejected, cannot alter parameterRanges, computeExampleRanges or positionInsideFence, and cannot change what remainingText retains beyond leaving more of it. So the only failure mode available to this diff is a false negative — a real call left as prose — never a false dispatch. I confirmed this by reading both added branches rather than inferring it from their shape.

That makes the false-negative surface the thing worth probing, and it is narrow:

  • Single ordinary call: the invoke's matchStart precedes its own parameter span, so matchStart >= start is false and it is recovered.
  • Sibling calls: the second starts after the first value's close and before its own parameter open. Pinned by the second new test.
  • A value documenting a stray parameter close tag: that close pops the value's own open, so the value's span ends early and the enclosing call's matchStart still precedes it. The enclosing call is recovered, and a later real call falls outside the shortened span.
  • A value documenting a stray parameter open tag: never popped, so it owns nothing. This is precisely the truncated-block case, and leaving it owning nothing is what keeps the existing rescan witness intact.

The residual false negative needs an unmatched open before a real call together with an unmatched close after it, and it degrades to this file's behaviour on main — no recovery. That is the safe side of the trade.

Loop safety, regex state and cost

  • The bare continue cannot stall the scan. TOOL_CALL_PATTERN.exec on a /g regex advances lastIndex past the match on success, and a match here necessarily spans literal open and close tags, so it is never zero-length. This contrasts deliberately with the reject branch, which sets lastIndex = resumeAt in order to rescan; skipping whole is the right choice here, since rescanning into a value is what dispatched the quoted call in the first place.
  • Module-level regex state is reset. PARAMETER_TAG_PATTERN.lastIndex = 0 at the top of closedParameterSpans, matching how the file already treats TOOL_CALL_PATTERN and PARAMETER_PATTERN at every entry point. No re-entrancy or cross-call leakage.
  • The quoted-run admission is consistent with the file's existing tag scanning. (?:[^>"']|"[^"]*"|'[^']*')* mirrors the tags pattern in computeExampleRanges, so a > inside an attribute value cannot end a tag early — the same hazard openTagEnd already closes for the rescan position.
  • No new stall hazard. closedParameterSpans is one linear scan. The per-match valueSpans.some(...) is numeric comparison only, while the same loop already calls positionInsideFence per block, which slices and splits the entire prefix. The addition is asymptotically no worse than code already in the loop and considerably cheaper per unit.

Both tests carry their weight

The first asserts extractXmlToolCalls returns an empty array and that tryRecoverXmlToolCalls reports recovered: false with remainingText equal to the whole input — so it pins the recovery contract, not just the extraction. The second asserts exactly one recovered call, read_file with args: { p: 'b.ts' }. That single assertion does three jobs at once: the quoted run_shell_command is not recovered, the outer write_file is still rejected by the borrowed-closer guard, and a real sibling outside the owning value survives. A blanket skip would fail it, which is what makes it a genuine scope control rather than a restatement of the first test.

Dependency and CI

This PR builds on #13437, which merged at f3dd8cf8; the patch context sits inside that PR's openTagEnd, so the base is merged code rather than an open sibling. The delta between the head the author self-reviewed and this head is four commits of merges from main touching unrelated packages — neither of this PR's two files changed, so its own contribution is byte-identical to what that review covered.

Lint & Static, Integration Tests (no-AK, No Sandbox) and both Desktop Shell legs pass at this head. Test (ubuntu-latest, Node 22.x) was still pending at review time; under this channel's rules a pending Test lane is not a gate and I did not poll it, so my verdict rests on the source read above rather than on that lane. It is the one check worth seeing land before merge.

Both changed files are under packages/core/src/core/, so require_code_owner_review applies. This approval is submitted as qqqys.

@yiliang114
yiliang114 added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit d4e7410 Oct 6, 2026
65 of 66 checks passed

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

APPROVE at 9cb4847d. Gates: 0 review threads (nothing unresolved), this is the first approval on the PR, mergeable=MERGEABLE, and CI is 20 pass / 0 fail / 53 skipped. Two lanes — Test (ubuntu-latest, Node 22.x) and Hosted process fault gates / MySQL 8.4 / Java 21 — have been stuck IN_PROGRESS since 2026-10-06T13:53Z, three days, and three review-pr automation lanes are cancelled. That is runner infrastructure, not a code signal, and this PR touches no Java. Rather than cite the stuck lane I replaced it: I ran the suite myself against this exact head and got 82/82 passing, which reproduces the body's "After" evidence independently.

I reviewed this file in #13437, so I read the change against the two guards that landed there — the quote-aware openTagEnd and the nested-opener test that replaced the borrowed-closer arithmetic. This is the third member of that family, and it is the one that closes a hole the second guard opened.

The defect was real, and I measured it rather than accepting the description

I built a differential harness in an isolated scratch directory over the head source and a variant with only the valueSpans skip removed, so each difference is attributable to this change:

case without the skip at head
A — write_file whose content quotes a whole run_shell_command call run_shell_command (none)
B — a real read_file sibling following A's block run_shell_command,read_file read_file
C — unclosed opener, then a well-formed call, then a stray closer read_file (none)
D — same as C with no stray closer read_file read_file
E — content containing don't write_file write_file
F — unbalanced quote inside the tag itself read_file read_file

Row A is the point of the PR and it is worse than "a call was misattributed": before this change the fallback dispatched run_shell_command with command: "rm -rf /tmp/x" taken from a value that was documenting syntax. Row B shows the skip is scoped to the span that owns the markup rather than to the rest of the turn, which is the property that makes it safe to add at all. Rows E and F are the controls I cared about most, because an unbalanced quote in a value is ordinary prose and a common apostrophe must not disturb pairing — neither does, since PARAMETER_TAG_PATTERN's quoted-run alternation only has to survive the tag, not the value.

C is a regression I can reproduce, and the obvious fix makes things worse

Pairing is positional: openStarts.pop() matches the most recent unmatched opener, and a closer carries no name, so nothing can validate the pair. When openers and closers do not balance, a stray closer pops an opener belonging to an earlier, never-closed element and the span it creates reaches across everything in between — including a well-formed call. Measured input, an unclosed content, then an intact read_file, then one extra closer:

<invoke name="write_file">
<parameter name="content">unclosed value
</invoke>
<invoke name="read_file"><parameter name="p">b.ts</parameter></invoke>
</parameter>

Head returns (none); without the skip it returns read_file. So a legitimate call is silently dropped and left as prose, with no error to the model — the same failure class #13437's nested-opener rewrite existed to eliminate.

Two things keep this non-blocking. The polarity is safe: it drops a call rather than executing one, in input that is already malformed enough that the fallback is running at all. And the intuitive hardening does not work — I tried clearing the stack at each </invoke> or </function> so a pending opener cannot reach across a completed call, and that breaks A: the quoted call carries its own </invoke> inside the value, so the reset pops content, content then owns nothing, and rm -rf /tmp/x is dispatched again. Worth recording so nobody spends a round on it. What I would ask for instead is cheap: a test pinning the current unbalanced-closer behaviour, so a future change to the pairing cannot widen the span silently. The docstring's claim that an unclosed opener "owns nothing" is true only while no stray closer arrives later, and D versus C is exactly that boundary.

A verified negative: the outer write_file is not cheaply recoverable, and trying corrupts the write

Case A now yields no calls at all, so the write the model actually asked for still does not land — #13492's symptom was "dropped the write and ran the example", and this fixes the dangerous half. Since closedParameterSpans already computes which region a value owns, the tempting follow-up is to reuse it to exempt quoted regions from the nested-opener guard and recover the outer call. I tested that instead of proposing it: neutralising only that guard does recover write_file, but with a truncated content —

"content": "Usage:\n<invoke name=\"run_shell_command\"><parameter name=\"command\">rm -rf /tmp/x"

The value's own </parameter></invoke>\n tail is gone, because TOOL_CALL_PATTERN is lazy so match[0] ends at the quoted call's closer. That would be a silent corrupted write reporting success — strictly worse than the inert outcome this PR produces. So the guard's rejection is load-bearing here, the ownership information does not substitute for depth-aware block matching, and leaving the rescan as it was is the right call. Recovering the outer call needs </invoke> pairing at the block level, which is its own change; if #13492's reporter needs the write to land, that is the issue to file.

Scope of my read

Both diffs in full, plus recoverableToolCallBlocks, closedParameterSpans, PARAMETER_TAG_PATTERN and the guard at head. I did not review computeExampleRanges or positionInsideFence beyond confirming the skip happens before them and that valueSpans is derived from raw text like parameterRanges is, so a rejected block's data stays masked. Execution was in an isolated scratch tree; the repository checkout it borrowed node_modules from was not modified.

Vote effect

Both files are under packages/core/src/core/, so require_code_owner_review applies: one of wenshao, tanzhenxin, LaZzyMan, doudouOUC or qqqys must approve. This approval satisfies required_approving_review_count: 1 but not the code-owner requirement, so reviewDecision stays REVIEW_REQUIRED and mergeStateStatus stays BLOCKED until one of them submits.

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Correcting three factual errors in my approval above. None of them touch the findings — the A–F differential measurements, regression C and the verified negative about exempting the nested-opener guard all stand as measured.

I wrote that approval while working from a repository state I had fetched at the start of the review, and did not re-fetch before posting. The clock and the review list disagree with what I published:

  1. "This is the first approval on the PR" is false. @chiga0 approved at 14:11:18Z and @qqqys at 14:15:51Z, both at this same head 9cb4847d; mine is 14:26:20Z. My initial fetch predates both, and I did not refresh it.
  2. "Two lanes have been stuck IN_PROGRESS since 2026-10-06T13:53Z, three days … that is runner infrastructure" is false. The current time when I posted was 14:28Z on 2026-10-06, so those lanes were roughly 35 minutes old and legitimately in flight, not abandoned. I inferred "three days" from an unrelated PR's date instead of reading the clock. Test (ubuntu-latest, Node 22.x) has since completed successfully, so the lane that actually runs these 82 tests is green at head and independently corroborates the local run I substituted for it. web-shell E2E Smoke and Hosted process fault gates / MySQL 8.4 / Java 21 are still running; the three cancelled lanes are the review-pr automation, not code gates.
  3. The "Vote effect" section is wrong for the same reason as (1). qqqys is in the CODEOWNERS list for /packages/core/, so require_code_owner_review was already satisfied before my review landed. reviewDecision reads APPROVED, not REVIEW_REQUIRED, and no further code-owner approval is needed. My approval added a third vote to a requirement that was already met twice, rather than unblocking anything.

The practical effect is that this PR was already mergeable on review state before I voted, and my review contributed findings rather than a gate. What I would still ask for is the one cheap item from the approval: a test pinning the unbalanced-closer behaviour in case C, so a future change to the positional pop() pairing cannot silently widen a span across a well-formed call.

yiliang114 added a commit that referenced this pull request Oct 8, 2026
The rewritten "does not dispatch a call quoted inside a parameter value"
fixture no longer exercises the valueSpans skip: the new close-tag rescan
moves the scan past the quoted call, so dropping the guard left all 83
parser tests green. Add the smallest known discriminating input - with it,
removing the guard fails exactly this one test.

Co-authored-by: Qwen-Coder <[email protected]>
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.

6 participants