fix(cli): Stop after cancelled permissions#5258
Conversation
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
624e947 to
3d1dafc
Compare
|
@qwen-code /triage |
✅ Local runtime verification — fix confirmedI verified this PR locally with real test runs under The behavior changeBefore: only How I verifiedWorktree at PR head 1) PR shipped state — green
2) The new tests are genuine regression tests (base ✗) PR test files against base source: 25 failed / 162 passed. Those 25 are the 10 new permission-cancellation tests (non-question cancel / request-failure / reject-option / confirm-cleanup-failure / nested-subagent variants, + the 3 3) Rename-agnostic behavioral isolation (the load-bearing proof) Because the renamed-field assertion trips first on base, I added one probe that asserts only the tool-execution side effects (never the renamed field/message) and ran it on both sources — driving the real
This is the exact fix, isolated from the rename: on base a non-question permission cancel lets the next tool run; on the PR it is skipped. 4) Typecheck — no new type errors After Notes for the maintainer (non-blocking)
Recommendation: correct, well-tested, prioritizes the user's cancellation as intended. 👍 to merge. Verified by running the project's ACP 中文版(点击展开)✅ 本地真实运行验证 —— 修复已确认我在本地用 行为变化改动前:只有 验证方法在 PR head 1) PR 完整状态 —— 全绿
2) 新测试是真正的回归测试(base 失败) PR 测试文件跑 base 源码:25 失败 / 162 通过。 这 25 个 = 10 个新增的权限取消测试(非 question 取消 / 请求失败 / reject 选项 / confirm 清理失败 / 嵌套 subagent 等,外加 3 个 3) 与重命名无关的行为隔离(关键证据) 由于重命名字段的断言在 base 上会先失败,我额外加了一个探针,只断言工具执行的副作用(完全不引用被重命名的字段/消息),并在两个源码版本上运行 —— 直接驱动真实的
这就是该修复本身,且与重命名无关:在 base 上,非 question 权限取消会让下一个工具继续执行;在 PR 上它被跳过。 4) 类型检查 —— 无新增类型错误 在 worktree 里执行 给维护者的提示(不阻塞)
建议: 正确、测试充分,按设计优先尊重用户的取消。👍 可以合并。 通过在 PR-head worktree 下用 |
|
@qwen-code /triage |
|
Thanks for the PR, @doudouOUC! Template looks good ✓ On direction: this is a clear safety/correctness fix and a natural follow-up to #5218. When a user cancels a permission prompt (or it times out), the model should not silently continue executing subsequent tools — that violates the user's intent and creates a confusing experience where the UI shows "cancelled" but tools still run. This is squarely within the project's core mission around safe tool permissions. CHANGELOG has #5218 as the direct predecessor — this PR completes the work by removing the On approach: the scope feels right. The core change is removing a Moving on to code review. 🔍 中文说明感谢贡献,@doudouOUC! 模板完整 ✓ 方向:这是一个明确的安全/正确性修复,也是 #5218 的自然后续。当用户取消权限提示(或超时)时,模型不应该静默继续执行后续工具——这违反了用户意图,也会导致 UI 显示"已取消"但工具仍在运行的混乱体验。这完全符合项目围绕安全工具权限的核心使命。CHANGELOG 中有 #5218 作为直接前驱——本 PR 通过移除 方案:范围合理。核心改动是移除 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewThe change is straightforward and well-contained. Four files, all in What it does: Removes the Independent proposal vs PR approach: My independent proposal matched the PR almost exactly — remove the tool name guard, rename No critical issues found. The rename is complete (zero stale references to One minor note: the TestsUnit tests (PR code):
Typecheck: Clean across all packages (cli, core, sdk, webui). Tmux Smoke TestCLI starts and responds normally. The specific permission cancellation behavior requires the daemon/WebShell setup described in the PR's test plan ( 中文说明代码审查改动直接且集中。4 个文件都在 做了什么: 移除了 独立方案 vs PR 方案: 我的独立方案与 PR 几乎完全一致——移除工具名称判断,重命名 未发现关键问题。 重命名完整(代码库中零残留的 一个小注意:取消路径中的 测试单元测试(PR 代码):
类型检查: 所有包(cli、core、sdk、webui)均通过。 Tmux 冒烟测试CLI 正常启动并响应。具体的权限取消行为需要 PR 测试计划中描述的 daemon/WebShell 设置( — Qwen Code · qwen3.7-max |
ReflectionThis is a clean, well-scoped behavior fix that closes a real safety gap. The predecessor PR (#5218) stopped the turn only for The implementation is minimal and focused: remove a tool name guard, rename for generality, add fail-closed error handling for The test coverage is thorough — 187 tests pass, with dedicated tests for each cancellation scenario (non-question cancel, reject option, onConfirm failure, permission request failure, nested permissions). Typecheck is clean. The CLI smoke test confirms it builds and runs. I'd approve this. It solves a real user problem, the approach matches what I'd independently propose, and the code is maintainable. Verdict: Approve ✅ 中文说明反思这是一个干净、范围明确的行为修复,填补了一个真实的安全缺口。前驱 PR(#5218)仅对 实现是最小化且集中的:移除工具名称判断,重命名以反映通用性,为 测试覆盖充分——187 个测试通过,每种取消场景(非问题取消、拒绝选项、onConfirm 失败、权限请求失败、嵌套权限)都有专用测试。类型检查通过。CLI 冒烟测试确认可以构建和运行。 我会批准这个 PR。它解决了一个真实的用户问题,方案与我独立提出的方案一致,代码可维护。 结论:批准 ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
| answers: output.answers, | ||
| }); | ||
| } catch (error) { | ||
| if (outcome !== ToolConfirmationOutcome.Cancel) { |
There was a problem hiding this comment.
[Suggestion] The non-Cancel re-throw branch here (if (outcome !== ToolConfirmationOutcome.Cancel) { throw error; }) has no test coverage. The only test that mocks confirmationDetails.onConfirm to reject ('skips later tools when cancellation confirmation cleanup fails' at Session.test.ts:6598) always pairs it with outcome: 'cancelled', so the re-throw path — which is the behaviorally important case where the user chose a proceed option but the cleanup callback itself failed — is untested. A follow-up test should mock requestPermission to return a proceed outcome (e.g. ProceedOnce) and mock onConfirm to reject, then assert that runToolCalls itself rejects (rather than returning a stopAfterPermissionCancel result). Without that assertion, a future refactor that accidentally swallows the error here would go undetected.
— qwen3.7-max via Qwen Code /review
| const ASK_USER_QUESTION_CANCEL_SKIP_MESSAGE = | ||
| 'Skipped because ask_user_question was cancelled before the user answered; user input is required before continuing.'; | ||
| const PERMISSION_CANCEL_SKIP_MESSAGE = | ||
| 'Skipped because a permission request was cancelled before the user answered; user input is required before continuing.'; |
There was a problem hiding this comment.
[Suggestion] Now that the turn stops on any cancelled permission (not just ask_user_question), this skip message is attached to cases where "before the user answered" is inaccurate:
- A deliberate decline —
ToolConfirmationOutcome.Cancelis the only Deny/Reject outcome, so the user did answer. The new testskips later tools after selecting the reject permission optionrecords exactly this text on an explicit reject. - A transient
requestPermissionfailure (the catch ~L3765 also stops the turn and reuses this message for the later tools) — no user is involved at all.
The text is model-facing (it lands in each skipped call's functionResponse.error) and goes into the recorded transcript, so the wrong cause can mislead the model and anyone debugging the session.
| 'Skipped because a permission request was cancelled before the user answered; user input is required before continuing.'; | |
| 'Skipped because a prior permission request was cancelled, declined, or could not be completed; user input is required before continuing.'; |
中文
[建议] 现在只要任意权限请求被取消(不再限于 ask_user_question)就会终止整个回合,这条信息会被附加到两类“用户其实已经应答”的场景上:
- 用户主动拒绝——
ToolConfirmationOutcome.Cancel是唯一的“拒绝”结果,所以用户确实应答了。新测试skips later tools after selecting the reject permission option正好在显式拒绝时记录了这条文本。 requestPermission的瞬时失败(~L3765 的 catch 同样会终止回合,并对后续工具复用这条信息)——此时根本没有用户参与。
这条文本是面向模型的(会写入每个被跳过调用的 functionResponse.error),也会进入录制的对话记录,因此错误的原因描述会误导模型以及排查会话的人。建议改写措辞。
— claude-opus-4-8 via Claude Code /qreview
| 'Skipped because a permission request was cancelled before the user answered; user input is required before continuing.', | ||
| }); | ||
| expect(failedPermissionExecute).not.toHaveBeenCalled(); | ||
| expect(laterExecute).not.toHaveBeenCalled(); |
There was a problem hiding this comment.
[Suggestion] This test verifies the permission-failure error message, the skip message, and that neither execute function ran — but it doesn't assert that onConfirm(ToolConfirmationOutcome.Cancel) was called. The production code at Session.ts:3771 calls confirmationDetails.onConfirm(Cancel) as a compensating step when requestPermission fails. The neighboring test "skips later tools when cancellation confirmation cleanup fails" does assert this.
Without this assertion, a regression that removes or skips the compensating onConfirm(Cancel) call would go undetected for non-Agent tool types.
| expect(laterExecute).not.toHaveBeenCalled(); | |
| expect(failedPermissionExecute).not.toHaveBeenCalled(); | |
| expect(laterExecute).not.toHaveBeenCalled(); | |
| expect(onConfirm).toHaveBeenCalledWith( | |
| core.ToolConfirmationOutcome.Cancel, | |
| ); |
(This requires passing an explicit onConfirm spy to mockConfirmingTool in the test setup above.)
— qwen3.7-max via Qwen Code /review
What this PR does
This PR makes ACP permission cancellation stop the current turn for every tool permission request, not just
ask_user_question. When a permission vote resolves as cancelled, when the reject option maps toCancel, or when the permission request channel fails, the declined tool is recorded and later tool calls in the same model response are skipped. It applies the same fail-closed behavior to nested Agent tool permission requests so a subagent cancellation aborts the parent Agent turn instead of letting sibling or subsequent tools continue.Why it's needed
A daemon permission timeout resolves as cancelled after the configured permission timeout. Before this change, that cancellation only stopped the turn for
ask_user_question; ordinary shell, file, or MCP permission cancellations could still allow later tool calls from the same assistant step to run. This made a timed-out or explicitly cancelled permission prompt look cancelled in the UI while the model could continue executing follow-up tools.Reviewer Test Plan
How to verify
Run the focused ACP session tests and confirm cancelled ordinary permissions, selected reject options, permission request failures, and nested subagent permission cancellations all stop the turn and skip later tool calls. For an end-to-end check, start the daemon/WebShell, create a session, set approval mode to
default, prompt for two shell commands that write/tmp/qwen_cancel_first_5218and/tmp/qwen_cancel_second_5218, then cancel the permission request; the first tool should be reported as cancelled, the second should be skipped, the turn should complete withend_turn, and neither sentinel file should exist.Evidence (Before & After)
Before: a cancelled or timed-out non-
ask_user_questionpermission could still let later tool calls from the same assistant step continue. After: local daemon/WebShell HTTP/SSE verification emitted onepermission_request, the cancel vote returned200, the firstrun_shell_commandtool result was cancelled, the second tool result was skipped with the permission-cancel skip message,turn_complete.stopReasonwasend_turn, and both/tmp/qwen_cancel_first_5218and/tmp/qwen_cancel_second_5218were absent.Tested on
Environment (optional)
macOS local workspace with Node
v22.22.3and npm10.9.8; daemon/WebShell E2E usedQWEN_SERVER_TOKEN=codex-local-verify npm run dev:daemon -- --port 4170.Risk & Scope
Linked Issues
Follow-up to #5218.
中文说明
What this PR does
这个 PR 让 ACP 权限取消对所有工具权限请求都会停止当前 turn,而不再只针对
ask_user_question。当权限投票结果是 cancelled、当拒绝选项映射到Cancel、或者权限请求通道失败时,被拒绝的工具会被记录,同一个模型响应里的后续工具调用会被跳过。嵌套 Agent 工具里的权限请求也采用同样的 fail-closed 行为,因此 subagent 的权限取消会中止父级 Agent turn,而不会继续执行 sibling 或后续工具。Why it's needed
daemon 权限超时会在配置的权限超时后解析为 cancelled。在这个改动之前,这种取消只会让
ask_user_question停止 turn;普通 shell、file 或 MCP 权限取消仍可能让同一个 assistant step 里的后续工具继续运行。这会导致权限提示在 UI 上看起来已经取消或超时,但模型仍继续执行后续工具。Reviewer Test Plan
How to verify
运行聚焦的 ACP session 测试,确认普通权限取消、选择拒绝选项、权限请求失败、以及嵌套 subagent 权限取消都会停止 turn 并跳过后续工具。端到端验证可以启动 daemon/WebShell,创建 session,将 approval mode 设置为
default,发送要求两个 shell command 分别写入/tmp/qwen_cancel_first_5218和/tmp/qwen_cancel_second_5218的 prompt,然后取消权限请求;预期第一个工具显示 cancelled,第二个工具显示 skipped,turn 以end_turn完成,并且两个哨兵文件都不存在。Evidence (Before & After)
Before:取消或超时的非
ask_user_question权限请求仍可能让同一个 assistant step 里的后续工具继续执行。After:本地 daemon/WebShell HTTP/SSE 验证产生了一个permission_request,cancel vote 返回200,第一个run_shell_command工具结果为 cancelled,第二个工具结果为带 permission-cancel skip message 的 skipped,turn_complete.stopReason为end_turn,且/tmp/qwen_cancel_first_5218和/tmp/qwen_cancel_second_5218都不存在。Tested on
Environment (optional)
macOS 本地工作区,Node
v22.22.3,npm10.9.8;daemon/WebShell E2E 使用QWEN_SERVER_TOKEN=codex-local-verify npm run dev:daemon -- --port 4170。Risk & Scope
Linked Issues
Follow-up to #5218.