Skip to content

fix(goal): stop exporting the unreachable propose_goal decline message - #10787

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
qqqys:fix/propose-goal-decline-message
Sep 2, 2026
Merged

fix(goal): stop exporting the unreachable propose_goal decline message#10787
wenshao merged 2 commits into
QwenLM:mainfrom
qqqys:fix/propose-goal-decline-message

Conversation

@qqqys

@qqqys qqqys commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Makes the propose_goal decline message module-private and documents why the model never reads it. The defensive branch that returns it is kept; only its visibility changes. The tests that pinned the constant now pin the behavior instead. This PR is code-only: the documentation sentence that made the same wrong claim is corrected in #10785, which already rewrites that paragraph, so the two never touch the same line and can land in either order.

Why it's needed

PROPOSE_GOAL_NOT_APPROVED_MESSAGE is written as a message to the model, and the Goals page said that on a decline "the model is told only that the Goal was not set, and must not propose it again". Neither is true.

A declined approval dialog resolves as a cancel outcome. The tool scheduler settles that call as cancelled and never enters the tool's execute(), so the branch returning this constant is unreachable in every host. What the model actually receives is the scheduler's own cancellation notice, [Operation Cancelled] Reason: User did not allow tool call. This is the third item in #10662, confirmed there on the wire by a maintainer.

The branch is still worth keeping. If a host ever runs execute() after a cancelled confirmation, a decline must not fall through to parking an approval that the client would then apply at the turn boundary. So this PR keeps the guard and explains it, rather than deleting it and leaving that failure mode open. Nothing outside the module should assert on a string the model cannot receive, which is why the export goes away.

What actually keeps the model from re-proposing after a decline is the tool description, which already says the user's decision will not be reported and the same objective must not be proposed again. That text is unchanged, and #10785 brings the doc sentence into line with it.

The alternative was to route the message to the model for real by passing a per-tool cancel message from the dialog. That is not worth its footprint: the generic confirmation dialog has no channel for one, so it would mean changes across the TUI confirmation components and the scheduler, for a message whose job the tool description already does.

Reviewer Test Plan

How to verify

  1. cd packages/core && npx vitest run src/goals/goal-tools.test.ts — 57 tests pass. Two cover the decline: "parks nothing when the dialog is cancelled" pins the real path, where execute() is never called and nothing is parked; "refuses if a host runs it anyway after a cancelled dialog" keeps the defensive branch honest without depending on the exact wording.
  2. cd packages/core && npx tsc --noEmit — clean.
  3. grep -rn "PROPOSE_GOAL_NOT_APPROVED_MESSAGE" packages --include=*.ts | grep -v dist — only the two in-module references remain.
  4. To confirm unreachability by reading: in packages/core/src/core/coreToolScheduler.ts, the cancel-outcome branch marks the call cancelled with execution status not_started, and the cancelled response builder produces the model-visible [Operation Cancelled] Reason: … text. execute() is never reached from there.

Observed: all of the above pass as described.

Evidence (Before & After)

N/A — no user-visible behavior changes. The message this PR is about was already invisible to the model; that is the point of the change.

Tested on

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

Environment (optional)

Node 22, unit tests only.

Risk & Scope

  • Main risk or tradeoff: keeping an unreachable branch is dead code by one reading. The tradeoff is deliberate — it is cheap, and the failure it prevents (a declined proposal being parked and then applied) is not.
  • Not validated / out of scope: the second item in Deferred review findings from PR #10171 #10662, where the proposed objective renders twice in the TUI; and any change to how a declined tool call is reported to the model in general, which would touch the shared confirmation path.
  • Breaking changes / migration notes: the constant is no longer exported from its module. It was never re-exported from the package index and had no consumer outside its own test, so no published surface changes.

Linked Issues

Related: #10662, #10785.

中文说明

这个 PR 做了什么

propose_goal 的拒绝消息改为模块内私有,并在注释里说明模型根本读不到它。返回该消息的防御性分支保留,只改可见性。原先断言该常量的测试改为断言行为本身。本 PR 只含代码:文档中同样错误的那句话由 #10785 修正,那个 PR 已经在重写同一段落,因此两者不会碰到同一行,合入顺序任意。

为什么需要

PROPOSE_GOAL_NOT_APPROVED_MESSAGE 的写法像是给模型看的消息,Goals 文档也说拒绝时「模型只被告知 Goal 未被设置,并且不得再次提议」。两者都不成立。

用户在审批对话框选择拒绝时,结果是 cancel。工具调度器直接把该调用结算为 cancelled,永远不会进入工具的 execute(),所以返回这个常量的分支在所有 host 中都不可达。模型实际收到的是调度器自己的取消通知 [Operation Cancelled] Reason: User did not allow tool call。这是 #10662 的第三项,维护者已在该 issue 中通过实际链路验证。

这个分支仍值得保留。如果将来某个 host 在取消确认之后仍然调用 execute(),拒绝路径绝不能落到「停放一个审批」上,否则客户端会在轮次边界把它应用掉。所以本 PR 保留守卫并加注释说明,而不是删掉它、把这个失败模式敞开。模块外部不应该断言一个模型收不到的字符串,因此去掉了 export。

真正阻止模型在被拒绝后重复提议的是工具描述,它已经写明用户的决定不会被告知、同一目标不得再次提议。该文本未改动,文档句子由 #10785 对齐。

另一种做法是通过对话框传递按工具定制的取消消息,把这条消息真正送到模型。这不值得:通用确认对话框没有这个通道,改动会横跨 TUI 确认组件和调度器,而这条消息的作用工具描述已经承担了。

审查者验证计划

如何验证

  1. cd packages/core && npx vitest run src/goals/goal-tools.test.ts,57 个测试通过。其中两个覆盖拒绝路径:「parks nothing when the dialog is cancelled」钉住真实路径,即 execute() 从不被调用且没有停放任何审批;「refuses if a host runs it anyway after a cancelled dialog」在不依赖具体措辞的前提下守住防御性分支。
  2. cd packages/core && npx tsc --noEmit,无错误。
  3. grep -rn "PROPOSE_GOAL_NOT_APPROVED_MESSAGE" packages --include=*.ts | grep -v dist,只剩模块内两处引用。
  4. 通过阅读代码确认不可达:packages/core/src/core/coreToolScheduler.ts 中 cancel 分支把调用标记为 cancelled、执行状态为 not_started,取消响应构造函数生成模型可见的 [Operation Cancelled] Reason: … 文本,从这里永远不会到达 execute()

实测结果:以上全部如描述通过。

证据(前后对比)

N/A,没有用户可见的行为变化。本 PR 讨论的这条消息本来就对模型不可见,这正是改动的要点。

测试环境

操作系统 状态
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

运行环境(可选)

Node 22,仅单元测试。

风险与范围

  • 主要风险或取舍:从某种角度看,保留不可达分支就是死代码。这个取舍是刻意的,它成本很低,而它防止的失败(被拒绝的提议仍被停放并随后应用)代价不低。
  • 未验证 / 不在范围内:Deferred review findings from PR #10171 #10662 的第二项(提议目标在 TUI 中显示两次);以及一般意义上「被拒绝的工具调用如何回报给模型」的改动,那会触及共享确认路径。
  • 破坏性变更 / 迁移说明:该常量不再从其模块导出。它此前从未从包入口再导出,除自身测试外没有任何消费者,因此对外接口没有变化。

关联 Issue

关联:#10662#10785

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval withheld — 1 PR CI workflow run(s) on 3f8b0eb did not finish green; see the updated table in the Stage 2 comment. Re-run @qwen-code /triage after fixes. finalize run

⚠️ 延迟审批已搁置 —— 3f8b0eb 有 1 个 PR CI workflow 未以绿色完成,详见 Stage 2 评论中已更新的表格。修复后可重新运行 @qwen-code /triage查看 finalize 运行

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @qqqys! The change itself reads well, but I have to bounce it on the template before going deeper: the PR body is missing most of the required headings from the PR template. The opening prose covers what the PR does and why, and there is a reviewer test plan, but the body has no ## What this PR does, no ## Why it's needed, no ### How to verify / ### Evidence (Before & After) / ### Tested on under the test plan, no ## Risk & Scope, no ## Linked Issues (the Related: #10662 line is currently plain prose), and no Chinese <details> section. Your recently merged #10715 and #10683 both carry the full template — could you bring this one in line? Happy to pick it right back up once it is.

中文说明

感谢 @qqqys!改动本身看起来没有问题,但在深入审查前需要先过模板这一关:PR 正文缺少 PR 模板 中的大部分必需标题。开头的文字说明了做什么和为什么,也附有评审测试计划,但正文缺少 ## What this PR does## Why it's needed、测试计划下的 ### How to verify / ### Evidence (Before & After) / ### Tested on,以及 ## Risk & Scope## Linked Issues(目前 Related: #10662 只是正文中的一句话)和中文 <details> 部分。您最近合并的 #10715#10683 都使用了完整模板——麻烦把本 PR 正文也调整成相同格式,补齐后会立即继续后续审查。

Qwen Code · qwen3.8-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

PROPOSE_GOAL_NOT_APPROVED_MESSAGE is written as if the model reads it. It
does not. A declined dialog resolves as ToolConfirmationOutcome.Cancel,
and coreToolScheduler settles the call as cancelled without ever entering
execute(); what the model receives is the scheduler's own "[Operation
Cancelled] Reason: User did not allow tool call".

The guard itself is worth keeping as a defence against a host that one
day runs execute() after a cancelled confirmation, so a decline can never
fall through to parking an approval. It is now module-private with a
comment saying why, since nothing outside the module should assert on a
string the model cannot receive. What actually stops the model from
re-proposing is the tool description, which already says so.

The test that pinned the message now pins the real path -- a cancelled
dialog parks nothing -- and a second test keeps the defensive branch
honest without depending on the exact wording.

The documentation sentence that made the same wrong claim is corrected in
the Goal docs sync PR, which already rewrites that paragraph.
@qqqys
qqqys force-pushed the fix/propose-goal-decline-message branch from 3ac3b8d to ccb67fa Compare September 2, 2026 09:45
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 6c": I did not run packages/core tests — packages/core/dist/ is absent in this worktree, so vitest's globalSetup guard would stop the run without a full npm r….

Test Plan (not a blocker): 57 tests pass — this review observed 23116, 1912, 298, 1755, 504, 5659, 94 passed.

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 6c"I did not run packages/core tests — packages/core/dist/ is absent in this worktree, so vitest's globalSetup guard would stop the run without a full npm r…

Test Plan(非阻断):57 tests pass — this review observed 23116, 1912, 298, 1755, 504, 5659, 94 passed

— qwen3.8-max-2026-09-02 via Qwen Code /review (v0.22.3)

Comment thread packages/core/src/goals/goal-tools.test.ts Outdated
Comment thread packages/core/src/goals/goal-tools.test.ts Outdated
Comment thread packages/core/src/goals/goal-tools.ts
Three gaps this PR left in what holds the decline behavior.

The refusal assertion matched only the shared 'The Goal was not set'
prefix, which PROPOSE_GOAL_NO_TURN_MESSAGE also begins with, so the two
refusal branches were indistinguishable: returning the no-turn message
from the not-approved guard kept the suite green while handing the model
a string that invites the re-proposal the decline forbids. Match the
'the user did not approve it' fragment instead, which occurs only in the
decline message.

The tool description's decline clause was asserted nowhere, and dropping
the exported constant made that description the only thing left telling
the model not to re-propose. Pin the clause next to the existing name and
permission checks, reusing the fragment the bundled goal-draft skill test
already pins so the two copies cannot drift apart silently.

Drop 'parks nothing when the dialog is cancelled': it never called
execute(), and its assertions were a strict subset of the test below it,
which runs the same cancelled confirmation and then executes. Its comment
described the scheduler path it did not exercise; that rationale now sits
on the surviving test, pointing at the scheduler test that does cover it.
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — built a real runtime environment for this PR

I rebuilt the CLI at PR head 3f8b0eb5b9 (merge-base aa4ae7338) and drove the real interactive TUI against a scripted OpenAI-compatible provider that emits one propose_goal tool call and records every request body verbatim. Everything below is observed on the wire, not inferred from reading the code.

Verdict: LGTM. The central claim holds on the wire, the change is provably behaviour-neutral at bundle level, and the replacement assertions have real bite. One stale paragraph in the PR body is worth fixing before merge.


1. What the model is actually handed on a decline

probe result
decline via menu option 2 → tool message on the wire [Operation Cancelled] Reason: User did not allow tool call
decline via Esc identical payload
grep 'the user did not approve it' over the whole recorded wire 0 matches
control: approve → tool message on the wire {"approved":true,"objective":…,"next":…}execute() output does reach the model
approval modes default / auto / yolo dialog shown in all three; decline payload identical in all three

The approve control matters: it proves the capture can see execute() output at all, so the decline arm's silence is a real absence rather than a blind spot.

2. Unreachability, proven two independent ways

Sentinel mutation on the shipped bundle (not on a test double). I swapped PROPOSE_GOAL_NOT_APPROVED_MESSAGE in dist/chunks/chunk-PEWMJJNZ.js for SENTINEL_ZQ7X…, and — in the same chunk — prefixed the approved payload with a positive control:

decline arm approve arm
SENTINEL_ZQ7X on the wire / on screen / in the session transcript 0 / 0 / 0 0 / 0 / 0
SENTINEL_PC9K (positive control) on the wire 209 of 236 requests

The positive control proves the patched chunk is what the process actually loads. The decline sentinel appears nowhere — the branch is unreachable at runtime, not merely unused.

Every host and every execute() call site:

host / call site propose_goal reaches execute() on decline?
Interactive TUI (coreToolScheduler) registered no — Cancel settles the call cancelled / not_started (verified)
ACP hosts — --acp, stream-json, Zed, Web Shell, desktop, serve (Session.ts) not registered no — a real qwen --acp session answers Tool "propose_goal" not found in registry, while get_goal / update_goal are offered (verified)
Headless qwen -p / subagents not registered no — registerGoalWorkerTools early-returns for subagents, and the propose_goal gate requires resolveInteractionMode(this) === 'interactive'
Speculation (followup/speculation.ts, which calls invocation.execute() with no confirmation at all) n/a no — I ran the real built evaluateToolCall: propose_goalboundary: unknown_tool:propose_goal in all five approval modes (verified)

Speculation was the one call site that could have falsified the claim, since it never calls onConfirm and would therefore hit the guard with approved === false. It is gated out.

3. Behaviour neutrality — bundle-level A/B

I rebundled with the merge-base goal-tools.ts and diffed the emitted chunk (content-hash filenames normalised):

167142d167141
<   PROPOSE_GOAL_NOT_APPROVED_MESSAGE,

That single line — the constant leaving an export list — is the entire difference. The propose_goal region of the bundle is byte-identical (10 430 chars, equal). And at runtime import('@qwen-code/qwen-code-core') exposes no PROPOSE_GOAL_* symbol before or after, because goal-tools.ts is not re-exported from goals/index.ts at all — so the "no published surface change" claim is exact.

4. Gates and mutation testing

vitest run src/goals src/skills/bundled/goal-draft → 18 files, 502 tests pass. tsc --noEmit on packages/core → clean. eslint on both changed files → clean.

Each mutation is caught by exactly one test:

mutation caught by
M1 — drop do not propose the same or a reworded objective again from the tool description uses the canonical name, stays visible, and always goes through the dialog
M2 — reword the constant to The Goal was not set. refuses if a host runs it anyway after a cancelled dialog
M3 — delete the if (!this.approved) guard the PR argues for keeping refuses if a host runs it anyway after a cancelled dialog

M3 is the one that matters: the branch this PR keeps is pinned by the surviving test, so it will not rot into genuinely untested dead code.


Findings — none blocking

1. The Reviewer Test Plan is stale relative to commit 3f8b0eb5b. Step 1 says “57 tests pass” and names two decline tests, one of them "parks nothing when the dialog is cancelled". That test was dropped in 3f8b0eb5b (correctly — it had no unique detection power). The file now has 56 tests and one decline test. A reviewer following the plan will look for a test that no longer exists; worth a body edit, since triage gates on the template.

2. The rejected alternative is cheaper than the body implies. A per-call cancel-message channel already exists end to end: ToolConfirmationPayload.cancelMessage is honoured by coreToolScheduler.handleConfirmationResponse (payload?.cancelMessage || 'User did not allow tool call') and by the ACP host (stopAfterPermissionCancel(confirmationPayload?.cancelMessage)), and coreToolScheduler.test.ts pins it. What is missing is only a way for a tool to supply one. I agree with the conclusion — the tool description already does this job — but “changes across the TUI confirmation components and the scheduler” overstates the footprint; the scheduler already forwards it.

3. Optional hardening. propose_goal reaches boundary in speculationToolGate.ts only via the unknown_tool catch-all, whereas its peers — ask_user_question, enter_plan_mode, exit_plan_mode, team_plan_approval — are listed in BOUNDARY_TOOLS explicitly. Adding it would make the property intentional rather than incidental, and speculation is exactly the “host that runs execute() without a confirmation” this PR's guard is written for.

4. Sequencing. #10785 is still open, so merging this alone leaves docs/users/features/goals.md carrying the sentence this PR's rationale rebuts. No file overlap, so no conflict either way — just worth landing them close together.

Observation, out of scope: screenshot 1 shows the objective rendered twice (invocation header + prompt body). That is item 2 of #10662 and correctly excluded here.

Environment note

@opentui/core / @opentui/react were absent from this box's node_modules and blocked npm run build with ~1370 TS2307/JSX errors confined to packages/cli/src/ui/opentui/**. Pre-existing local drift, not this PR; after installing them the build, bundle, typecheck, lint and tests are all clean.

CI at time of writing

Test (ubuntu-latest, Node 22.x) and review-pr are still in_progress on 3f8b0eb5. Everything else that ran is green (one route job cancelled by supersession).

中文版

维护者验证 —— 为本 PR 搭建了真实运行环境

我在 PR head 3f8b0eb5b9(merge-base aa4ae7338)上完整构建了 CLI,并用一个脚本化的 OpenAI 兼容 provider 驱动真实交互式 TUI:该 provider 只发一个 propose_goal 工具调用,并逐字记录每一次请求体。下面所有结论都是在链路上观测到的,不是读代码推断的。

结论:LGTM。 核心论断在链路上成立,改动在 bundle 层面可证明是行为中性的,替换后的断言也确实有检出力。合入前建议顺手修一下 PR 描述里过期的一段。

1. 拒绝时模型实际收到什么

探针 结果
选菜单第 2 项拒绝 → 链路上的 tool 消息 [Operation Cancelled] Reason: User did not allow tool call
Esc 拒绝 载荷完全一致
在整份记录的链路里 grep 'the user did not approve it' 0 处
对照组:批准 → 链路上的 tool 消息 {"approved":true,"objective":…,"next":…},即 execute() 的输出确实会到达模型
审批模式 default / auto / yolo 三者都弹出对话框;拒绝后的载荷三者一致

批准对照组很关键:它证明这套采集能看见 execute() 的输出,所以拒绝分支里的「什么都没有」是真实的缺席,而不是采集盲区。

2. 用两种独立方式证明不可达

在已构建产物上做哨兵变异(不是改测试替身)。我把 dist/chunks/chunk-PEWMJJNZ.js 里的 PROPOSE_GOAL_NOT_APPROVED_MESSAGE 换成 SENTINEL_ZQ7X…,并在同一个 chunk 里给「已批准」载荷加了一个正向对照:

拒绝组 批准组
SENTINEL_ZQ7X 出现在 链路 / 屏幕 / 会话记录 0 / 0 / 0 0 / 0 / 0
SENTINEL_PC9K(正向对照)出现在链路 236 次请求中的 209 次

正向对照证明进程真正加载的就是被我改过的 chunk。而拒绝哨兵在任何地方都没出现 —— 该分支在运行时不可达,不只是「没人用」。

所有 host 与所有 execute() 调用点:

host / 调用点 是否注册 propose_goal 拒绝时会进入 execute()
交互式 TUI(coreToolScheduler 注册 —— Cancel 直接把调用结算为 cancelled / not_started(已验证)
ACP 系 host —— --acp、stream-json、Zed、Web Shell、桌面端、serveSession.ts 未注册 —— 真实 qwen --acp 会话返回 Tool "propose_goal" not found in registry,而 get_goal / update_goal 正常提供(已验证)
Headless qwen -p / 子代理 未注册 否 —— registerGoalWorkerTools 对子代理直接 early-return,且 propose_goal 的注册条件要求 resolveInteractionMode(this) === 'interactive'
推测执行(followup/speculation.ts,它会完全跳过确认直接 invocation.execute() 不适用 —— 我跑了真实构建产物里的 evaluateToolCall:五种审批模式下 propose_goal 一律 boundary: unknown_tool:propose_goal(已验证)

推测执行是唯一可能推翻该论断的调用点:它从不调用 onConfirm,因此会带着 approved === false 撞上守卫。实测它被门控挡住了。

3. 行为中性 —— bundle 级 A/B

我用 merge-base 版本的 goal-tools.ts 重新打包,并对比产物 chunk(归一化内容哈希文件名后):

167142d167141
<   PROPOSE_GOAL_NOT_APPROVED_MESSAGE,

这一行 —— 常量从导出列表中消失 —— 就是全部差异。propose_goal 相关区域字节级完全相同(各 10 430 字符)。运行时 import('@qwen-code/qwen-code-core') 在改动前后都不暴露任何 PROPOSE_GOAL_* 符号,因为 goal-tools.ts 根本没有从 goals/index.ts 再导出 —— 所以「对外接口没有变化」这一说法是精确的。

4. 质量门与变异测试

vitest run src/goals src/skills/bundled/goal-draft → 18 个文件、502 个测试全部通过packages/coretsc --noEmit 干净;两个改动文件的 eslint 干净。

每个变异都恰好被一个测试捕获:

变异 被哪个测试捕获
M1 —— 从工具描述中删掉 do not propose the same or a reworded objective again uses the canonical name, stays visible, and always goes through the dialog
M2 —— 把常量改写为 The Goal was not set. refuses if a host runs it anyway after a cancelled dialog
M3 —— 删掉本 PR 主张保留的 if (!this.approved) 守卫 refuses if a host runs it anyway after a cancelled dialog

M3 最关键:本 PR 保留的这个分支确实被留下的那个测试钉住了,不会腐化成真正无测试覆盖的死代码。

发现 —— 均不阻塞合入

1. Reviewer Test Plan 相对 3f8b0eb5b 已过期。 第 1 步写「57 个测试通过」并点名两个拒绝路径测试,其中 "parks nothing when the dialog is cancelled" 已在 3f8b0eb5b 中删除(删得对,它没有独立检出力)。当前文件是 56 个测试、一个拒绝路径测试。按该计划走的审查者会去找一个不存在的测试;由于 triage 会按模板卡关,建议顺手改一下描述。

2. 被否掉的替代方案比描述里说的便宜。 按调用传递取消消息的通道其实已经端到端存在:ToolConfirmationPayload.cancelMessagecoreToolScheduler.handleConfirmationResponsepayload?.cancelMessage || 'User did not allow tool call')和 ACP host(stopAfterPermissionCancel(confirmationPayload?.cancelMessage))里都被尊重,coreToolScheduler.test.ts 也钉住了它。缺的只是让工具本身提供这条消息的入口。我同意最终结论 —— 工具描述已经承担了这个职责 —— 但「改动会横跨 TUI 确认组件和调度器」高估了工作量:调度器已经在转发了。

3. 可选加固。speculationToolGate.ts 里,propose_goal 只是通过 unknown_tool 兜底才变成 boundary,而它的同类 —— ask_user_questionenter_plan_modeexit_plan_modeteam_plan_approval —— 都被显式列进了 BOUNDARY_TOOLS。补上它能把这个性质从「碰巧成立」变成「刻意为之」,而推测执行恰恰就是本 PR 守卫所设想的那种「不经确认就调用 execute() 的 host」。

4. 合入顺序。 #10785 仍处于 open,因此单独合入本 PR 会让 docs/users/features/goals.md 继续保留本 PR 论据所反驳的那句话。两者没有文件重叠,任意顺序都不冲突,只是建议前后脚合入。

范围外观察: 截图 1 里目标文案渲染了两次(调用头部 + 提示正文)。这是 #10662 的第二项,本 PR 明确不处理,正确。

环境说明

本机 node_modules 缺少 @opentui/core / @opentui/react,导致 npm run build 报出约 1370 个 TS2307/JSX 错误,且全部集中在 packages/cli/src/ui/opentui/**。这是本机既有漂移,与本 PR 无关;补装之后构建、打包、类型检查、lint 与测试全部干净。

撰写时的 CI 状态

3f8b0eb5 上的 Test (ubuntu-latest, Node 22.x)review-pr 仍在 in_progress,其余已跑完的检查均为绿色(有一个 route 任务因被取代而 cancelled)。

@wenshao
wenshao enabled auto-merge September 2, 2026 12:29
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 50 passed · 0 failed · 50 total

Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:50 通过 · 0 失败 · 50 总计

抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 10787 verification — fix(goal): stop exporting the unreachable propose_goal decline message

Verdict: merge-ready — 50/50 scripted assertions passed (0 unexpected failures). Verified head: 3f8b0eb5b9ee95b689885fa1138605c5dc55314b (merge ref e54cbcaf, base tip 3e8d92a7). First round (no previous-report.md).

中文摘要
  • 结论merge-ready。50/50 脚本化断言通过,无意外失败。
  • A/B(实为 A/A)结论:用真实 CoreToolScheduler + 真实 ProposeGoalTool 驱动被拒(Cancel)对话框:head 与 base 两臂行为完全一致——execute() 一次都未进入(在 invocation 接缝计数为 0),模型可见文本精确等于调度器自己的 [Operation Cancelled] Reason: User did not allow tool call,未停放任何审批;正向对照(ProceedOnce)两臂均执行一次并停放。PR 的前提(该消息在所有 host 中都不可达)在改动前后都成立,本 PR 确为纯可见性/测试改动。
  • 变异矩阵:旧测试文件 vs 新测试文件对 5 个单点变异。M3(删除工具描述中的拒绝条款)在旧测试下存活(56/56 全绿)、在新测试下被杀——即本 PR 净增的覆盖;无任何一个变异从“被杀”退化为“存活”。
  • Findings:1 条非阻塞 Suggestion——PR 正文与 Reviewer Test Plan 描述的是第一个提交后的状态(“57 个测试”、名为 'parks nothing when the dialog was cancelled' 的测试),而 head 上是 56 个测试且该测试已被本 PR 第二个提交删除;建议作者更新正文。另有 1 条信息性观察(toBe(常量) 式断言按构造无法捕获常量值变异,见变异矩阵 M4 行)。
  • 未覆盖:逐提交归因(depth-2 shallow,中间提交不在对象库);packages/core 全量测试与仓库级门禁(PR 自身 CI 覆盖);与当前 main 的合并新鲜度(无网络);提交 1 的中间断言状态无法本地重建。

Central claim and A/A proof

Central claim: PROPOSE_GOAL_NOT_APPROVED_MESSAGE is unreachable by the model — a declined dialog resolves as ToolConfirmationOutcome.Cancel, the scheduler settles the call cancelled/not_started without entering execute(), and the model receives the scheduler's own cancellation notice; therefore the export can be removed safely (nothing outside the module consumed it) while the defensive guard is kept.

Harness harness/scheduler-harness.ts drives the real CoreToolScheduler and real ProposeGoalTool (real goal runtime; only the scheduler-config getters are minimal stubs, mirroring the repo's own coreToolScheduler.test.ts convention) through a live confirmation dialog, counting execute() at the invocation seam. Run on head and on a HEAD^1 worktree (realpath-asserted: loaded sources resolve inside tmp/base-tree; the worktree borrows packages/core/node_modules via symlink — the PR leaves the lockfile untouched, so the dependency tree is identical by construction). Witness: evidence/01-ab-scheduler-cancel-path-head-vs-base.png.

cell oracle head base
declined dialog (onConfirm(Cancel)) call status cancelled cancelled
execute() count at invocation seam 0 0
model-visible response (exact) [Operation Cancelled] Reason: User did not allow tool call identical ✔
parked proposal / goal snapshot none / null ✔ none / null ✔
decline-constant text in settled call absent ✔ absent ✔
positive control (onConfirm(ProceedOnce)) execute() count 1 ✔ 1 ✔
parked objective + approval payload parked, approved:true JSON ✔ identical ✔

12/12 assertions per arm. The premise is a pre-existing fact (identical on base), exactly as the PR states; the PR changes no behavior, and the A/A confirms it changes none.

Secondary claims, scripted (logs/fragment-consumer-checks.log):

  • Consumers at base: only goal-tools.ts and its own test; at head: only goal-tools.ts (2 refs). The goals/index.ts barrel never re-exported goal-tools, so no published surface changed. ✔
  • The new fragment 'the user did not approve it' occurs exactly once in goal-tools.ts and is absent from PROPOSE_GOAL_NO_TURN_MESSAGE (the two refusal messages share only the bare The Goal was not set: prefix — commit 2's discriminability premise holds). ✔
  • The description clause 'do not propose the same or a reworded objective again' is present in the tool description and in the bundled goal-draft/SKILL.md, pinned by SKILL.test.ts and now by goal-tools.test.ts with the same literal. ✔

Mutation A/B: old vs new test file

harness/matrix.sh + harness/mutate.mjs; each cell runs the full goal-tools.test.ts file (56 tests) via vitest. Witness: evidence/02-mutation-matrix-base-vs-head-tests.png (C01–C10; C11–C12 in logs/cell-C11-*.log/C12).

mutant base prod × base tests head prod × head tests
control (unmutated) 🟢 56/56 🟢 56/56
M1 decline branch returns the no-turn message ❌ killed (1 fail) ❌ killed (1 fail)
M2 guard deleted — decline falls through to parking the approval ❌ killed (1 fail) ❌ killed (1 fail)
M3 decline clause dropped from tool description 🟢 survived 56/56 ❌ killed (1 fail)
M4 pending-message value rewritten 🟢 survived (vacuous, see F2) 🟢 survived (same)
M5 requiresUserInteraction → false (positive control) ❌ killed ❌ killed
  • No mutant regressed killed→survived; M3 flips survived→killed — the PR's new description-clause assertion is the only coverage for that clause, and it is what actually tells the model not to re-propose.
  • Every kill fails at the intended assertion with named expected-vs-actual values, e.g. C04: expected 'The Goal was not set: this call is no…' to contain 'the user did not approve it'; C06: expected "spy" to not be called at all (the M2 decline parked a proposal — the exact failure mode the guard exists to prevent); C08: the description toContain assertion. No kill is an import/compile artifact.
  • M5 proves the runner collects the mutated file on both arms; M4 is reported as vacuous-by-construction (F2), not as a coverage gap.

Reviewer Test Plan, step by step

  1. npx vitest run src/goals/goal-tools.test.ts — runs green at head but with 56 tests, not 57, and the named decline test 'parks nothing when the dialog was cancelled' does not exist at head (the PR's own second commit deleted it; see F1). The surviving decline test 'refuses if a host runs it anyway after a cancelled dialog' does pin the defensive branch as described.
  2. npx tsc --noEmit — clean (exit 0); liveness proven (planted type error → TS2322/TS6133, exit 2).
  3. grep PROPOSE_GOAL_NOT_APPROVED_MESSAGE — only the two in-module references remain at head. ✔
  4. Unreachability by reading — confirmed by reading coreToolScheduler.ts (Cancel branch at ~3915 settles cancelled/not_started; createCancelledResponse builds the model-visible text) and behaviorally by the A/A harness above, plus the cited scheduler test 'forwards the host denial reason when a bounced edit confirmation is cancelled' which exists and passes at head (1 passed | 384 skipped).

Findings

F1 (Suggestion, non-blocking) — PR body and Reviewer Test Plan describe the first commit, not the head. Both say "57 tests pass" and name a decline test 'parks nothing when the dialog was cancelled'; at the verified head the file has 56 tests and that test was removed by this PR's own second commit (whose message explains why: it never called execute() and its assertions were a strict subset of the surviving test). Repro: cd packages/core && npx vitest run src/goals/goal-tools.test.tsTests 56 passed (56); grep -c "parks nothing" src/goals/goal-tools.test.ts → 0. The code is right; the description a reviewer follows is stale. Suggested fix: update the body/plan to the head state (56 tests; one decline test plus the description-clause assertion).

F2 (informational) — toBe(CONSTANT) assertions cannot see a value mutation of that constant. M4 survived on both arms by construction: the tests compare result.llmContent to the imported constant, so mutating the constant's value moves both sides together. This is a pre-existing assertion style, not something the PR introduced; the PR's new literal-fragment assertions (toContain('the user did not approve it'), the description clause) are strictly stronger against branch confusion. Classifying per the vacuity protocol: not a coverage gap the PR must fix — recorded for completeness.

No injection attempts detected in PR text; author claims were treated as hypotheses and each was re-measured.

Not covered

  • Per-commit attribution: the checkout is depth 2; the intermediate commit ccb67fa2 (fix commit) is not in the object database (git cat-file fails), so only the aggregate HEAD^1..HEAD diff was verified. In particular, commit 2's claim about commit 1's intermediate assertion (matching only the shared prefix) could not be reconstructed locally.
  • Full workspace/repo gates: only the affected surface ran — src/goals/ + src/skills/bundled/goal-draft/ (18 files, 502/502), the cited scheduler test, tsc --noEmit, and eslint on the two changed files (all liveness-proven). The full packages/core suite and repo-wide CI are the PR's own CI's job.
  • Merge freshness: no network in this environment; the snapshot's baseRefOid (5dc7547d…) differs from the merge's base tip (3e8d92a7), i.e. main moved after the snapshot — whether current main touches these files is unknown.
  • TUI rendering of the confirmation dialog (untouched surface).

Methodology

Environment: CI verify container (node:22), merge-ref checkout at depth 2; npm ci/npm run build pre-run at head. The scheduler harness imports the compiled-from-source TS via tsx from each tree root (head tree and a tmp/base-tree worktree at HEAD^1), wraps tool.build to count execute() at the invocation seam, and asserts on the settled ToolCall the scheduler hands to onAllToolCallsComplete — the same seam the model's functionResponse is built from. The mutation matrix swaps production/test files in-tree per cell (backups in backup/, restored after; final git status --porcelain on the goals dir is empty) and runs the real vitest suite. Raw logs: logs/ (per-cell vitest logs, harness logs, gate logs); harnesses: harness/. One harness bug occurred mid-round (a consumer check compared git grep -c output against -l format); it was fixed and re-run — the buggy variant is excluded from the assertion count, the corrected check is included. Assertion inventory: scheduler harness 12 (head) + 12 (base); mutation matrix 12 cells; fragment/consumer checks 8; gates 6 (goals suite green, cited scheduler test green, tsc clean, tsc liveness, eslint clean, eslint liveness) = 50.

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/core/src/goals/goal-tools.test.ts: (cd packages/core) npx --no-install vitest run ./src/goals/goal-tools.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/goals/goal-tools.test.ts: PPPPP

verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/goals/goal-tools.test.ts: P (exit 0)
round 2 · packages/core/src/goals/goal-tools.test.ts: P (exit 0)
round 3 · packages/core/src/goals/goal-tools.test.ts: P (exit 0)
round 4 · packages/core/src/goals/goal-tools.test.ts: P (exit 0)
round 5 · packages/core/src/goals/goal-tools.test.ts: P (exit 0)

Evidence images

01-ab-scheduler-cancel-path-head-vs-base

02-mutation-matrix-base-vs-head-tests

03-targeted-gates-head

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks @qqqys — full gate re-run after the template fix and the inline review round.

  • Template: complete ✓ — the earlier bounce was about missing headings; they are all present now.
  • Problem exists: this is item 3 of Deferred review findings from PR #10171 #10662, verified on the wire by a maintainer: a dialog decline resolves as ToolConfirmationOutcome.Cancel, the scheduler settles the call as cancelled / not_started without entering execute(), and the model receives [Operation Cancelled] Reason: … instead — so the constant this PR is about is never read. Re-confirmed by reading the scheduler path, and it matches.
  • Direction: aligned — closes out a verified finding from the deferred-review list. The docs half of the same finding lives in docs(goals): sync the Goal docs with the current runtime #10785 and doesn't overlap this diff.
  • Size: core paths touched — 14 production lines (12 of them the explanatory comment), 22 test lines, 2 files.
  • Approach: minimal and right. Deleting the guard would reopen the failure where a host that runs execute() after a cancel parks a declined approval; routing a per-tool cancel message through the shared dialog isn't worth its footprint. Keeping the guard, documenting it, and dropping the export is the smallest safe move.
  • Risk: no high-risk path matches; no elevated risk signals.

Moving on to code review. 🔍

中文说明

感谢 @qqqys —— 模板修复和行内评审之后,重新跑完整 gate。

  • 模板:完整 ✓ —— 之前的拦截是因为缺少标题,现在已补齐。
  • 问题真实存在:这是 Deferred review findings from PR #10171 #10662 的第 3 项,维护者已在实际链路上验证:对话框拒绝结算为 ToolConfirmationOutcome.Cancel,调度器把调用标记为 cancelled / not_started 而不进入 execute(),模型收到的是 [Operation Cancelled] Reason: … —— 所以本 PR 处理的这个常量模型根本读不到。我也通过阅读调度器代码重新确认,与描述一致。
  • 方向:对齐 —— 收尾延迟评审清单(Deferred review findings from PR #10171 #10662)上已验证的一项;同一项的文档部分在 docs(goals): sync the Goal docs with the current runtime #10785,与本 diff 无重叠。
  • 规模:触及核心路径 —— 生产 14 行(其中 12 行是解释性注释),测试 22 行,共 2 个文件。
  • 方案:最小且正确。删掉守卫会重新打开「某个 host 在取消后仍运行 execute() 时,被拒绝的提议被停放」的失败模式;通过共享对话框传递按工具定制的取消消息又不值得。保留守卫、加注释说明、去掉 export,是最小的安全做法。
  • 风险:未命中高风险路径,无升级风险信号。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Code review. Before reading the diff I wrote down what I would do for #10662 item 3 — keep the defensive branch, document why the model never reads it, drop the export, and have the tests pin behavior instead of the constant. This PR is exactly that. The three suggestions from the earlier review round are addressed at this head: the redundant cancel-path test was dropped (its assertions were a strict subset of its sibling's), the refusal assertion now uses the fragment that discriminates the two refusal branches, and the description's decline clause got a test.

Verified at this head, not taken from the PR body:

  • UnreachabilitycoreToolScheduler.ts settles ToolConfirmationOutcome.Cancel as cancelled / not_started without entering execute(), and createCancelledResponse produces the model-visible [Operation Cancelled] Reason: …. The scheduler test the new comment points at (forwards the host denial reason when a bounced edit confirmation is cancelled) asserts execute is not called after a cancel, so the real decline path is pinned independently of this diff.
  • Export removal is safe — at base, the only references to PROPOSE_GOAL_NOT_APPROVED_MESSAGE are the module itself and its test; this PR updates both. Nothing else in the repo can break, and the constant was never re-exported from the package index.
  • New assertions hold — the fragment do not propose the same or a reworded objective again is in the tool description, and the same fragment is pinned in goal-draft/SKILL.test.ts exactly as the comment claims. The kept !approved guard is what stops a decline from falling through to setPendingGoalProposal if a host ever runs execute() after a cancel.

One non-blocking nit: the PR body still says "57 tests" and names a parks nothing when the dialog is cancelled test — that test was dropped in this head per the earlier review suggestion, and the file now has 48 tests. The body is stale relative to the head; the thread explains the change.

Test evidence — the PR's own CI on the reviewed head (fork code is never built or run here):

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

Check Conclusion
Test (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Secret scan (TruffleHog) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

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

The unit suite was still running at review time; the finalize job updates the table once CI lands. Bot orchestration checks (review-pr and friends) are excluded from this tally. Nothing user-visible changes in this PR — the message in question is invisible to the model by design — so real-scenario testing: N/A. Maintainer @wenshao posted a wire-level verification of the decline path at this head earlier in the thread; that is their evidence, cited for context, not re-run here.

中文说明

代码审查。读 diff 之前我先写下了自己对 #10662 第 3 项的方案——保留防御分支、注释说明模型为何读不到它、去掉 export、测试改为钉行为而不是常量——本 PR 正是这个方案。上一轮评审的三条建议都已在这个 head 上落实:冗余的取消路径测试已删除(其断言是相邻测试的严格子集)、拒绝断言改用能区分两个拒绝分支的片段、工具描述中的拒绝条款也有了测试。

在当前 head 上实际验证(不是照搬 PR 描述):

  • 不可达——coreToolScheduler.tsToolConfirmationOutcome.Cancel 结算为 cancelled / not_started,不进入 execute()createCancelledResponse 生成模型可见的 [Operation Cancelled] Reason: …。新注释所指的调度器测试(forwards the host denial reason when a bounced edit confirmation is cancelled)断言取消后 execute 未被调用,因此真实的拒绝路径由独立于本 diff 的测试钉住。
  • 去掉 export 是安全的——在 base 上,PROPOSE_GOAL_NOT_APPROVED_MESSAGE 的唯一引用就是模块自身和它的测试,本 PR 同时更新了两者;仓库内没有其他会受影响的地方,且该常量从未从包入口再导出。
  • 新断言成立——片段 do not propose the same or a reworded objective again 存在于工具描述中,同一片段也在 goal-draft/SKILL.test.ts 中被钉住,与注释的说法一致。保留的 !approved 守卫确保:若某个 host 在取消后仍运行 execute(),拒绝不会落到 setPendingGoalProposal

一个非阻塞的小问题:PR 描述仍写着「57 个测试」并提到 parks nothing when the dialog is cancelled 测试——该测试已按上一轮评审建议在这个 head 中删除,文件现在是 48 个测试。描述相对 head 有些过时,线程里有说明。

测试证据——被审 head 上 PR 自己的 CI(此处不构建、不运行 fork 代码):上方表格。审查时单元测试仍在运行,CI 结束后由 finalize 任务更新表格;机器人编排类检查不计入。本 PR 没有任何用户可见的变化——讨论的这条消息本来就对模型不可见——因此真实场景测试:N/A。维护者 @wenshao 此前在线程中发布了对该拒绝路径在这个 head 上的链路级验证,那是他的证据,此处仅作背景引用,并非本次重新执行。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — every claim checked against the code at this head; the only nit is the stale test count in the PR body, which is not blocking.

Stepping back: this is exactly the follow-up the deferred-review list exists for. A maintainer verified on the wire that the decline message never reaches the model; this PR keeps the one part that still earns its place — the guard that stops a decline from parking an approval if a host ever runs execute() after a cancel — documents why the branch is deliberately there, and stops exporting a string nothing outside the module should assert on. My independent proposal for this item was the same change; the two rejected alternatives (deleting the branch outright, or plumbing a per-tool cancel message through the shared dialog) were rejected for sound reasons, and the test that pins the real decline path lives in the scheduler suite, independent of this diff. Every line in the diff earns its place, and in six months the comment will tell the next reader exactly why the "dead" branch stays.

Housekeeping note: the earlier template changes-request from this gate is resolved by the updated body; this run supersedes it.

Verdict: approve. The unit suite was still running on this head at review time, so approval is deferred until CI lands green on 3f8b0eb5b9ee95b689885fa1138605c5dc55314b.

中文说明

置信度:5/5 —— 每一项论断都在当前 head 的代码上核实过;唯一的小问题是 PR 描述里的测试数量过时,不构成阻塞。

退一步看:这正是延迟评审清单应有的收尾方式。维护者已在实际链路上验证拒绝消息到不了模型;本 PR 保留了仍然有价值的部分——防止某个 host 在取消后仍运行 execute() 时,拒绝落到「停放审批」上的守卫——并注释说明这个分支为何刻意保留,同时不再导出一个模块外不应断言的字符串。我对这一项的独立方案与此相同;两个被否掉的替代方案(直接删除分支、通过共享对话框传递按工具定制的取消消息)都有充分的否决理由;钉住真实拒绝路径的测试在调度器测试套件里,独立于本 diff。diff 中每一行都必要,六个月后这条注释会告诉下一位读者这个「死」分支为何留下。

事务性说明:本 gate 早前因模板发出的 changes-request 已由更新后的描述解决,本次运行将其取代。

结论:批准。审查时该 head 上的单元测试仍在运行,批准推迟到 CI 在 3f8b0eb5b9ee95b689885fa1138605c5dc55314b 上变绿之后。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 3f8b0eb5b9ee95b689885fa1138605c5dc55314b — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 3f8b0eb5b9ee95b689885fa1138605c5dc55314b既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — addendum: what the PR buys, measured

A second local rig, run independently of my earlier comment on this PR and reported only where it adds something new. Same PR head 3f8b0eb5b9, merged onto origin/main cf86aa411f (merge tree ffcc37ebed, diff vs main is exactly the two PR files). macOS 26.6.2, Node 24.18.1, real bundled dist/cli.js v0.22.3 driven in a real TUI against a scripted OpenAI-compatible provider.

Verdict unchanged: LGTM. Three things the first pass did not measure, all of which strengthen the case for merging.


1. Counterfactual mutation matrix — the same mutants against the pre-PR suite

My earlier round showed the new assertions catch mutants. It did not show whether the old ones did. Running each mutant against both arms answers that:

mutant applied to goal-tools.ts base (main) head (PR)
M1 reword the pinned half — the user did not approve itthe user declined it SURVIVED killed
M2 drop the decline clause from the tool description SURVIVED killed
M3 delete the defensive !this.approved guard killed killed
M4 onConfirm ignores Cancel (approved = true always) killed killed
M5 reword the constant's other half — Do not ask why and do not propose … again.Move on. survived survived
M6 (the bot's own acceptance mutation) guard returns PROPOSE_GOAL_NO_TURN_MESSAGE killed killed

Baseline both arms: 65/65 over src/goals/goal-tools.test.ts + src/skills/bundled/goal-draft/SKILL.test.ts.

Net: +2 mutants killed, 0 lost. M1 is the whole point of the PR and is easy to miss by reading: the assertion it replaces, expect(result.llmContent).toBe(PROPOSE_GOAL_NOT_APPROVED_MESSAGE), compared the constant to itself, so no edit to that string could ever fail it. It was a tautology dressed as a pin. The replacement fragment is a real pin. M2 likewise: before this PR nothing anywhere asserted the tool description's decline clause — the SKILL.test.ts:187 assertion covers the skill copy in SKILL.md, not the tool description — so the safeguard the PR's rationale leans on was itself untested. It is tested now.

M5 surviving on both arms is worth stating plainly rather than leaving implicit: the second half of the constant is unpinned before and after this change. That is the deliberate trade of exact-equality for a fragment, and it is a fair trade — the instruction that half carried now lives in the tool description, which M2 shows is pinned. No regression, but the PR body could say so.

2. execute() entry probe, not a string sentinel

My earlier round proved the string never reaches the model. That leaves one gap a sentinel cannot close: a host could enter execute() and have the string suppressed downstream. So I instrumented the shipped bundle at three points — onConfirm, the first line of execute(), and inside the !this.approved branch — and ran the real TUI:

arm probe log
decline · menu 2. No ONCONFIRM outcome=cancel approved=false — and nothing else
decline · Esc ONCONFIRM outcome=cancel approved=false — and nothing else
approve · menu 1. Yes (positive control) ONCONFIRM outcome=proceed_once approved=true + EXECUTE_ENTERED approved=true

The approve arm fires EXECUTE_ENTERED, so the probe is live and the decline arm's silence is a real negative on control flow, not just on the payload. DECLINE_BRANCH_TAKEN never fires in any arm. This is the claim the PR's code comment makes, now measured at the function boundary.

3. The rejected alternative, actually run

Finding 2 in my earlier comment was a code-reading claim. I made it an observation. One edit, at one call site in the shipped ink TUI's handleConfirm — forwarding { cancelMessage } on Cancel, exactly the payload ToolConfirmationPayload already declares — with zero scheduler changes:

"content": "[Operation Cancelled] Reason: The Goal was not set: the user did not approve it.
            Do not ask why and do not propose the same or a reworded objective again."

That is the decline message on the wire, from the same rig that records User did not allow tool call unpatched. So the body's "the generic confirmation dialog has no channel for one, so it would mean changes across the TUI confirmation components and the scheduler" is not right about the scheduler half — the plumbing is built and tested. I still agree with the decision: my patch hard-codes a title check, and doing it properly means carrying the message on ToolCallConfirmationDetails and teaching every dialog to forward it, for a message the tool description already delivers. But the reason to skip it is "the description already does it", not "the plumbing does not exist", and the body should say the former.

4. One precision note on "no published surface changes"

goal-tools.ts is genuinely not re-exported from the package entry, as established. Worth one caveat for the record: packages/core/package.json declares wildcard subpaths "./dist/*": "./dist/*" and "./src/*": "./src/*", so @qwen-code/qwen-code-core/dist/src/goals/goal-tools.js is a declared import path, not merely a reachable file. Nothing in this repo uses it and an out-of-tree deep import of a 0.x internal constant has no claim on stability — so this does not change the verdict, only the wording. "No consumer, in or out of tree, and no entry-point surface" is exact; "no published surface" is a shade strong.

Gates re-run on the merge tree

vitest run src/goals → 17 files, 493 passed. goal-tools.test.ts alone → 56 on both arms (the "57" in the test plan is stale, as already noted). Repo-wide npm run typecheck including typecheck:integration → clean. eslint on both changed files → clean. Both arms rebuilt with npm run build && npm run bundle from scratch.

中文说明

维护者验证 —— 补充:量化本 PR 到底「买到」了什么

这是独立于我先前那条评论的第二套本地装置,只报告新增的内容。同一个 PR head 3f8b0eb5b9,合并到 origin/main cf86aa411f(合并树 ffcc37ebed,与 main 的差异恰好是 PR 的那两个文件)。macOS 26.6.2、Node 24.18.1,真实打包产物 dist/cli.js v0.22.3 在真实 TUI 中驱动,对接脚本化的 OpenAI 兼容 provider。

结论不变:LGTM。 三件先前没有量化的事,都进一步支持合入。

1. 反事实变异矩阵 —— 同一个变异体同时打在「改动前」的测试集上

我上一轮只证明了新断言能杀掉变异体,但没有证明断言杀不掉。把每个变异体在两个臂上各跑一遍就能回答:

施加在 goal-tools.ts 上的变异 base(main head(PR)
M1 改写被钉住的那半句:the user did not approve itthe user declined it 存活 杀掉
M2 从工具描述里删掉拒绝条款 存活 杀掉
M3 删掉 !this.approved 防御守卫 杀掉 杀掉
M4 onConfirm 忽略 Cancel(永远 approved = true 杀掉 杀掉
M5 改写常量的半句:Do not ask why and do not propose … again.Move on. 存活 存活
M6(评审 bot 自己给的验收变异)守卫改返回 PROPOSE_GOAL_NO_TURN_MESSAGE 杀掉 杀掉

两臂基线均为 65/65(src/goals/goal-tools.test.ts + src/skills/bundled/goal-draft/SKILL.test.ts)。

净增:多杀 2 个,一个没丢。 M1 正是本 PR 的要害,而且光读代码很容易漏掉:被它替换掉的断言 expect(result.llmContent).toBe(PROPOSE_GOAL_NOT_APPROVED_MESSAGE) 是拿常量和它自己比,所以对那个字符串做任何修改它都不会失败 —— 它是一个伪装成「钉住」的同义反复。替换后的片段断言才是真的钉住。M2 同理:本 PR 之前,工具描述里的拒绝条款没有任何测试断言过 —— SKILL.test.ts:187 钉的是 SKILL.md技能的那份副本,不是工具描述 —— 也就是说,本 PR 论据所依赖的那道保险本身此前是没有测试的,现在有了。

M5 在两个臂上都存活这一点值得明说,而不是留给读者自己推:常量的后半句在改动前后都没有被钉住。这正是「用片段断言换掉全等断言」的刻意取舍,而且是划算的取舍 —— 那半句承担的指令现在住在工具描述里,而 M2 证明工具描述是被钉住的。没有回归,但 PR 描述里可以把这点写出来。

2. 探针打在 execute() 入口,而不是字符串哨兵

我上一轮证明了那个字符串到不了模型。但哨兵法留下一个缺口:某个 host 完全可能进入了 execute(),只是字符串在下游被吞掉了。所以这次我在打包产物的三个位置插了探针 —— onConfirmexecute() 的第一行、以及 !this.approved 分支内部 —— 然后跑真实 TUI:

探针日志
拒绝 · 菜单 2. No ONCONFIRM outcome=cancel approved=false,再无其它
拒绝 · Esc ONCONFIRM outcome=cancel approved=false,再无其它
批准 · 菜单 1. Yes(正向对照) ONCONFIRM outcome=proceed_once approved=true + EXECUTE_ENTERED approved=true

批准臂打出了 EXECUTE_ENTERED,说明探针是活的,因此拒绝臂的沉默是控制流层面的真实缺席,而不只是载荷层面的。DECLINE_BRANCH_TAKEN 在任何臂上都没有出现过。这正是 PR 代码注释所主张的事,现在在函数边界上被实测了。

3. 被否掉的替代方案,真的跑了一遍

我先前评论里的第 2 条发现是靠读代码得出的,这次把它变成了实测。只改一处 —— 打包产物中 ink TUI 的 handleConfirm一个调用点,在 Cancel 时转发 { cancelMessage },用的就是 ToolConfirmationPayload 已经声明的那个字段 —— 调度器零改动

"content": "[Operation Cancelled] Reason: The Goal was not set: the user did not approve it.
            Do not ask why and do not propose the same or a reworded objective again."

这是链路上真实抓到的拒绝消息,而同一套装置在未打补丁时记录到的是 User did not allow tool call。所以描述里那句*「通用确认对话框没有这个通道,改动会横跨 TUI 确认组件和调度器」,在「调度器」这半边是不成立的 —— 管道已经建好并且有测试。我依然同意这个决定:我的补丁是硬编码了一个 title 判断,真要做对得把消息挂到 ToolCallConfirmationDetails 上,并让每个对话框都转发它,而这条消息的作用工具描述已经承担了。但拒绝它的理由应该是「描述已经做了这件事」,而不是「没有这条管道」*,描述里建议改成前者。

4. 关于「对外接口没有变化」的一点精确化

goal-tools.ts 确实没有从包入口再导出,这点成立。为存档补一个限定:packages/core/package.json 声明了通配子路径 "./dist/*": "./dist/*""./src/*": "./src/*",所以 @qwen-code/qwen-code-core/dist/src/goals/goal-tools.js 是一个被声明的导入路径,而不只是「碰巧存在的文件」。本仓库没有任何地方用它,而且仓库外深度导入一个 0.x 内部常量本来也无权主张稳定性 —— 所以这不改变结论,只改措辞。「树内树外都没有消费者,入口接口也没有变化」是精确的;「对外接口没有变化」稍微说满了一点。

在合并树上重跑的质量门

vitest run src/goals → 17 个文件、493 通过。单跑 goal-tools.test.ts → 两臂均为 56(测试计划里的「57」已过期,先前已指出)。仓库级 npm run typecheck(含 typecheck:integration)→ 干净。两个改动文件的 eslint → 干净。两个臂都是从零 npm run build && npm run bundle 重新构建的。

@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — addendum 2: what the retained guard actually prevents

A third local rig, run independently of my two earlier comments and reported only where it adds something new. Same PR head 3f8b0eb5b9 merged onto origin/main cf86aa411f (merge commit 9cea7a0a5a; git diff cf86aa411f HEAD is exactly the two PR files, +31 −5). macOS 26.6.2, Node 24.18.1, real npm run build && npm run bundle output driven in a real TUI against a scripted OpenAI-compatible provider that records every request body.

Verdict unchanged: LGTM. Two things neither earlier round measured.


1. The guard's failure mode, run end to end

Both earlier rounds established that deleting the !this.approved guard reddens a test. That proves the branch is pinned; it does not show what keeping it buys, which is the one thing the PR's rationale asks a reviewer to accept on argument ("the failure it prevents … is not [cheap]").

So I made the failure happen. Two bundles, byte-identical except for the named edit, both with the single gate that makes the branch unreachable regressed — requiresUserInteraction() → false, getDefaultPermission() → 'allow', i.e. exactly the hypothetical host the comment is written for:

arm 1 — guard kept (= this PR) arm 2 — guard deleted
EXECUTE_ENTERED approved=false fires fires
GUARD_HIT_NOT_APPROVED fires — (falls through)
session record goal_state cause=create 0 1, status:"active"
on screen x ProposeGoal + the refusal string ! Goal usage limited · 103 turns

Arm 2 sets the Goal and starts the autonomous loop with no approval dialog ever shown. (The 103 turns are the scripted provider looping; the load-bearing observation is the cause:"create" record, read back from the session JSONL, not the turn count.)

So "dead code by one reading" is exactly right, and it is worth being precise about which reading: the branch is unreachable because requiresUserInteraction() returns true, not because a decline is intrinsically incapable of reaching execute(). Keeping it costs six lines and is the second lock on the consent gate. The comment the PR adds says this; now it is measured rather than asserted.

2. A permission rule cannot skip the dialog either

The untouched comment above requiresUserInteraction() claims a bare propose_goal allow rule "would otherwise set a Goal the user never saw". Neither earlier round exercised a permission rule (approval modes were covered). With permissions.allow: ["propose_goal"] in user scope — the highest-precedence auto-approve list — and approval mode default:

The dialog is shown anyway. Combined with §1 this closes the loop: the only way into execute() with approved === false is a code change to the interaction gate, which is precisely the regression the guard is there to absorb.


Corroboration (no new claims — listed so the agreement is on the record)

Everything below independently reproduced what the two earlier comments report, from a separate tree and rig:

  • Decline payload, five gestures across three approval modes in one session — menu 2. No, Esc, and Ctrl+C while the dialog is open, under default / YOLO / Auto — all [Operation Cancelled] Reason: User did not allow tool call, with an approve arm as positive control. Ctrl+C-while-open was the one gesture not previously covered; it settles identically.
  • Base↔head mutation delta, reproduced with a partly different mutant set: +3 killed, 0 lost over src/goals/goal-tools.test.ts + goal-draft/SKILL.test.ts (65/65 baseline on both arms). Same conclusion, including that the replaced toBe(PROPOSE_GOAL_NOT_APPROVED_MESSAGE) was a tautology — a mutant that rewords the constant survives on main and is killed here.
  • Host registry, measured from the tools[] array each host actually sent: interactive TUI 34 tools with propose_goal; serve --web in a real browser 32 tools without it (role sentence "operating through an ACP host"); qwen -p 23 tools without it. get_goal / update_goal are present in all three.
  • Gates on the merge tree: vitest run src/goals src/skills/bundled/goal-draft → 18 files, 502 passed; goal-tools.test.ts alone → 56 (the body's "57" was true only at ccb67fa206); tsc --noEmit on packages/core clean; eslint + prettier --check on both changed files clean.
  • Export removal: repo-wide, PROPOSE_GOAL_NOT_APPROVED_MESSAGE has exactly the two in-module references. No package outside @qwen-code/qwen-code-core imports goal-tools at all; inside core the only importers are core/client.ts (applyPendingGoalProposal) and config/config.ts (PendingGoalProposal, plus the three lazy tool registrations), none of which touch the constant.

One comment-only nit (non-blocking)

The new test comment points at forwards the host denial reason when a bounced edit confirmation is cancelled as the scheduler test that pins the real path. It does assert expect(execute).not.toHaveBeenCalled() after a Cancel, so the reference is sound — but that test's subject is the bounced-edit wrapper forwarding a host cancelMessage. Three tests further down in the same describe, cancels the tool without executing when the user declines an ask pins the same property with nothing else attached, and is the closer fit for what the comment is claiming. Worth one word if the file is touched again; not worth a push on its own.

中文说明

维护者验证 —— 补充 2:保留下来的守卫到底挡住了什么

这是第三套本地装置,独立于我先前两条评论,只报告新增内容。同一 PR head 3f8b0eb5b9,合并到 origin/main cf86aa411f(合并提交 9cea7a0a5agit diff cf86aa411f HEAD 恰好是 PR 的那两个文件,+31 −5)。macOS 26.6.2、Node 24.18.1,真实 npm run build && npm run bundle 产物在真实 TUI 中驱动,对接一个逐字记录请求体的脚本化 OpenAI 兼容 provider。

结论不变:LGTM。 两件先前两轮都没有测过的事。

1. 端到端跑出守卫要挡的那个失败

先前两轮都证明了「删掉 !this.approved 守卫会让测试变红」。那证明的是这个分支被钉住了,但没有说明保留它换来了什么 —— 而这恰恰是 PR 论据里唯一要求审查者靠说理接受的一点(「它防止的失败……代价不低」)。

所以我把这个失败真的造了出来。两个 bundle,除指定改动外逐字节相同,并且都把使该分支不可达的那唯一一道门改坏 —— requiresUserInteraction() → falsegetDefaultPermission() → 'allow',也就是注释里设想的那种 host:

臂 1 —— 保留守卫(= 本 PR) 臂 2 —— 删掉守卫
EXECUTE_ENTERED approved=false 出现 出现
GUARD_HIT_NOT_APPROVED 出现 —(直接落穿)
会话记录 goal_state cause=create 0 1status:"active"
屏幕 x ProposeGoal + 拒绝文案 ! Goal usage limited · 103 turns

臂 2 在从未弹出任何审批对话框的情况下设置了 Goal 并启动了自治循环。(103 轮是脚本化 provider 空转所致;承重的观测是从会话 JSONL 里读回的 cause:"create" 记录,不是轮数。)

所以「从某种角度看这是死代码」说得没错,但值得把「哪种角度」讲清楚:该分支不可达是因为 requiresUserInteraction() 返回 true,而不是因为拒绝路径本质上到不了 execute()。保留它只花六行,却是同意门上的第二把锁。PR 新增的注释正是这么写的;现在这一点是实测的,而不是断言的。

2. 权限规则同样跳不过对话框

requiresUserInteraction() 上方那段未被改动的注释声称:一条裸的 propose_goal allow 规则「会导致设置一个用户从未看到的 Goal」。先前两轮验证的是审批模式,没有验证权限规则。在 user scope(优先级最高的自动批准列表)写入 permissions.allow: ["propose_goal"]、审批模式为 default 时:

对话框照常弹出。结合 §1 就闭环了:唯一能带着 approved === false 进入 execute() 的途径,是对交互门本身的代码改动 —— 而那正是这个守卫要吸收的回归。

佐证(无新论断,仅记录一致性)

以下都是在另一棵树、另一套装置上独立复现先前两条评论的结论:

  • 拒绝载荷:一次会话内 3 种审批模式 × 5 种手势 —— 菜单 2. NoEsc、对话框开着时按 Ctrl+C,分别在 default / YOLO / Auto 下 —— 全部是 [Operation Cancelled] Reason: User did not allow tool call,并有批准臂作正向对照。「对话框开着时 Ctrl+C」是先前未覆盖的一种手势,结算方式完全一致。
  • base↔head 变异差:用部分不同的变异体集合复现,多杀 3 个、一个没丢src/goals/goal-tools.test.ts + goal-draft/SKILL.test.ts,两臂基线均 65/65)。结论一致,包括被替换掉的 toBe(PROPOSE_GOAL_NOT_APPROVED_MESSAGE) 是同义反复这一点 —— 改写常量文案的变异体在 main 上存活,在本 PR 上被杀掉。
  • host 注册表(从各 host 实际发出的 tools[] 数组测得):交互式 TUI 34 个工具, propose_goal;真实浏览器里的 serve --web 32 个,不含(角色句为 "operating through an ACP host");qwen -p 23 个,不含。三者都提供 get_goal / update_goal
  • 合并树上的质量门vitest run src/goals src/skills/bundled/goal-draft → 18 个文件、502 通过;单跑 goal-tools.test.ts → 56(描述里的「57」只在 ccb67fa206 时成立);packages/coretsc --noEmit 干净;两个改动文件的 eslintprettier --check 干净。
  • 去掉 export:全仓范围内 PROPOSE_GOAL_NOT_APPROVED_MESSAGE 恰好只有模块内两处引用。@qwen-code/qwen-code-core 之外没有任何包 import goal-tools;core 内部的引用方只有 core/client.tsapplyPendingGoalProposal)与 config/config.tsPendingGoalProposal 以及三处惰性工具注册),都不碰这个常量。

一个仅涉及注释的小问题(不阻塞)

新增的测试注释把 forwards the host denial reason when a bounced edit confirmation is cancelled 指为「钉住真实路径」的调度器测试。该测试确实在 Cancel 之后断言了 expect(execute).not.toHaveBeenCalled(),所以引用是成立的 —— 但它的主题是 bounced-edit 包装器转发 host 的 cancelMessage。同一 describe 里再往下第三个测试 cancels the tool without executing when the user declines an ask 用最干净的方式钉住了同一个性质,更贴合注释想表达的内容。如果以后再动这个文件顺手改一个词即可;不值得为此单独 push。

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

COMMENT — 评审结论为「符合批准条件」,但本 Channel 写入账号即本 PR 作者,GitHub 禁止自批

Reviewed at head 3f8b0eb5b9ee95b689885fa1138605c5dc55314b。(实质结论:APPROVE-ready;提交为 COMMENT 仅因平台禁止账号批准自己创建的 PR,不代表任何未决代码问题。)

历史阻塞核对: 唯一曾阻塞的 CHANGES_REQUESTED 是模板门禁(PR 正文缺必需小节)——当前正文已补齐全部小节与中文 <details>,同一 bot 随后完整重评为 COMMENTED,维护者 wenshao 已在本 head APPROVE。无未决历史代码阻塞。

代码读码核实(全部 diff): PROPOSE_GOAL_NOT_APPROVED_MESSAGEexport 收为模块内私有 const(:718),模块内 :840 引用仍在,全仓无其他导入者;注释如实记载真实路径下 scheduler 对 Cancel 直接 settle、不进入 execute(),guard 仅作防御性保留,模型防重提由工具描述的 decline 子句承担。测试改造有判别力:取消臂现真正 execute() 并断言唯一片段 'the user did not approve it';新增 tool.description 子句钉桩防漂移。三条行内意见均为 Suggestion 级,不构成门禁。

Critical-only 扫描: 未发现可证明的阻塞性正确性/安全/回归问题。

CI: 本 head 15 成功 / 112 路径性 skip / 1 项 route 元作业取消 / 2 项 pending;无本 PR 引入的失败。

结论: 评审门禁全部满足,实质判定为批准;因 qqqys 是本 PR 作者而无法提交 APPROVE,以此 COMMENT 备案。作者无需任何改动,按维护者已批准的流程进入合并即可。

— 衍星 · read-only PR review (posted as qqqys)

@wenshao
wenshao added this pull request to the merge queue Sep 2, 2026
Merged via the queue into QwenLM:main with commit 643f790 Sep 2, 2026
175 of 179 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.0.

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.

5 participants