Skip to content

fix(core): reliably deliver manual plan-exit notices - #7744

Merged
doudouOUC merged 1 commit into
QwenLM:mainfrom
doudouOUC:fix/manual-plan-exit-notice-delivery
Jul 26, 2026
Merged

fix(core): reliably deliver manual plan-exit notices#7744
doudouOUC merged 1 commit into
QwenLM:mainfrom
doudouOUC:fix/manual-plan-exit-notice-delivery

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR makes the one-shot manual Plan-exit notice reliable across every supported live conversation. It separates approval-mode events from per-conversation delivery cursors, so the main conversation and each interactive agent can receive the same inherited mode change independently while replacement chats in the same conversation do not redeliver it.

Notice delivery now happens at the live chat history commit boundary after proactive compression and hard-rescue checks. The notice is appended as the final user-content text part, including tool-result continuations, and synchronous setup rollback safely restores the claim. Provider retries and fallbacks reuse the committed history entry, while reactive context-overflow compression preserves the exact notice when rebuilding the retry history.

The opt-in is enabled for main chats and interactive agent chats. Fork/speculation chats, headless agents, workflows, memory helpers, and compaction side queries remain excluded.

Why it's needed

The implementation merged in #7682 consumes a single pending boolean while assembling only UserQuery and Cron reminders. That leaves ACP/daemon direct sends, tool-result and steering continuations, hooks, and interactive agents uncovered. Because the flag is shared, one conversation can also consume the notice before another live conversation sees it.

Moving delivery to the chat history commit boundary covers all model-bound message kinds without protocol-specific injection paths. Separating the mode event from each conversation's cursor preserves one-shot behavior without allowing one conversation to steal another's notice.

Reviewer Test Plan

How to verify

  1. In the main TUI conversation, enter Plan mode, send a turn, leave Plan with Shift+Tab, and make the next model request a normal prompt or tool-result continuation. Confirm the request contains the manual-exit notice exactly once, names the current mode, keeps function responses before the notice, and does not append another notice on the following turn.
  2. In ACP, switch a live session from Plan to Default with session/set_mode, then send the next prompt or continuation. Confirm the next wire payload contains one notice without requiring ACP-specific injection logic.
  3. Open an interactive agent around the same inherited mode transition. Confirm the agent and main conversation each receive one notice independently, while fork and headless chats do not consume it.
  4. Exit Plan through an approved exit_plan_mode flow and confirm no manual-exit notice is added.
  5. Force synchronous send setup failure after claim and confirm the next send receives the restored notice. Force provider retry, fallback, and reactive context-overflow compression and confirm live history contains one notice and every rebuilt request retains it.
  6. Confirm the focused automated coverage passes: 776 tests across the changed conversation, agent, prompt, and exit-tool suites, plus the 18 focused approval-mode state-machine tests.

Evidence (Before & After)

Scenario Before After
Main UserQuery / Cron One-shot notice delivered Unchanged one-shot behavior, now committed in live chat history
ACP/daemon direct send, tool result, steering, or hook continuation Notice could be missed Next model-bound user content receives the notice
Main conversation and interactive agent sharing a mode event First consumer could clear the global flag Each conversation independently receives the inherited event once
Replacement chat using the same conversation configuration Global state made ownership ambiguous Existing cursor prevents redelivery
Reactive context-overflow compression after notice commit Compressed retry could lose the notice Exact committed notice is retained once in retry history

The macOS PTY wire capture reproduced the missing post-Shift+Tab signal with the global CLI and verified that the current bundle appends the exact notice on the next request, keeps the total historical count at one on later turns, and preserves tool-result ordering.

Tested on

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

Environment (optional)

macOS 26.4.1, Node v22.22.3, npm 10.9.8; local source build, Vitest, and PTY wire-payload capture. Live DashScope completion was unavailable in the sandbox because DNS resolution failed, so ACP and interactive-agent ownership were additionally verified through their exact construction and send paths.

Risk & Scope

  • Main risk or tradeoff: Delivery state is now owned jointly by approval-mode event snapshots and per-conversation cursors. The main risk is an incorrect ownership or rollback boundary; transition, inheritance, chat recreation, setup failure, retry, fallback, and compression cases have direct regression coverage.
  • Not validated / out of scope: Notice persistence across process restarts, local Windows/Linux execution, and confirmation that an external provider actually received a failed transport request are out of scope. A transport retry may resend the same request, but live history inserts the notice at most once.
  • Breaking changes / migration notes: None. The existing compatibility consume method remains available, and approval permissions, Plan approval, and tool execution semantics are unchanged.

Linked Issues

Closes #7745.

Refs #7671 (Problem 1). Follow-up to #7682 and complementary to #7673.

中文说明

本 PR 做了什么

本 PR 让手动退出 Plan 的一次性通知能够可靠覆盖所有受支持的当前进程内会话。实现将审批模式事件与每个会话的投递游标分离,因此主会话和每个交互式 Agent 可以分别收到同一个继承的模式变化,而同一会话中的 chat 重建不会重复投递。

通知现在在 proactive compression 和 hard-rescue 检查完成后的 live chat history 提交边界投递。通知作为 user content 的最后一个文本 part 追加,包含 tool-result 续轮;同步 setup 回滚会安全恢复 claim。provider retry 和 fallback 复用已经提交的 history entry,reactive context-overflow compression 在重建重试 history 时会保留完全相同的通知。

该 opt-in 只为主 chat 和交互式 Agent chat 开启。fork/speculation chat、headless Agent、workflow、memory helper 和 compaction side query 继续保持关闭。

为什么需要

#7682 中合入的实现只在 UserQuery 和 Cron reminder 装配期间消费一个全局 pending boolean,因此 ACP/daemon 直发、tool-result 和 steering 续轮、hook 以及交互式 Agent 仍然无法覆盖。由于该标志是共享的,一个会话还可能在其他 live conversation 看见通知之前先将它消费。

将投递移动到 chat history 提交边界后,无需增加协议专用注入路径即可覆盖所有 model-bound message 类型。将 mode event 与每个会话的 cursor 分离,则能维持 one-shot 行为,同时防止一个会话抢走另一个会话的通知。

审阅测试计划

如何验证

  1. 在主 TUI 会话中进入 Plan mode,发送一轮,然后通过 Shift+Tab 离开 Plan,并让下一次模型请求成为普通 prompt 或 tool-result continuation。确认请求只包含一次手动退出通知、通知写明当前模式、function response 位于通知之前,并且下一轮不会追加第二次通知。
  2. 在 ACP 中通过 session/set_mode 将 live session 从 Plan 切换到 Default,然后发送下一次 prompt 或 continuation。确认下一个 wire payload 包含一次通知,且不需要 ACP 专用注入逻辑。
  3. 在同一个继承模式变化前后打开交互式 Agent。确认 Agent 和主会话各自独立收到一次通知,而 fork 和 headless chat 不会消费它。
  4. 通过批准的 exit_plan_mode 流程离开 Plan,确认不会添加手动退出通知。
  5. 在 claim 后强制同步 send setup 失败,确认下一次发送收到恢复的通知。强制 provider retry、fallback 和 reactive context-overflow compression,确认 live history 只包含一次通知且每个重建请求都会保留它。
  6. 确认聚焦的自动化覆盖通过:变更涉及的 conversation、Agent、prompt 和 exit-tool 测试共 776 个,以及 18 个聚焦的 approval-mode 状态机测试。

证据(Before & After)

场景 之前 之后
主会话 UserQuery / Cron 能投递一次性通知 一次性行为不变,通知现在提交到 live chat history
ACP/daemon 直发、tool result、steering 或 hook continuation 可能漏掉通知 下一次 model-bound user content 会收到通知
主会话和交互式 Agent 共享 mode event 第一个消费者可能清除全局标志 每个会话各自接收一次继承事件
同一个 conversation config 上的 replacement chat 全局状态导致所有权不清晰 既有 cursor 防止重复投递
通知提交后发生 reactive context-overflow compression 压缩后的重试可能丢失通知 重试 history 中精确保留一次已提交通知

macOS PTY wire capture 使用全局 CLI 复现了 Shift+Tab 后缺少明确信号的问题,并验证当前 bundle 会在下一次请求中追加完全相同的通知、后续轮次的历史总数保持为一,以及 tool-result 顺序保持正确。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS 26.4.1、Node v22.22.3、npm 10.9.8;本地源码构建、Vitest 和 PTY wire-payload capture。由于 sandbox 中 DNS 解析失败,无法完成真实 DashScope provider 响应,因此 ACP 和交互式 Agent 所有权还通过其准确的构造与发送路径进行了验证。

风险与范围

  • 主要风险或权衡:投递状态现在由 approval-mode event snapshot 和每会话 cursor 共同拥有。主要风险是 ownership 或 rollback 边界错误;transition、inheritance、chat recreation、setup failure、retry、fallback 和 compression 均有直接回归覆盖。
  • 未验证 / 超出范围:跨进程重启的通知持久化、Windows/Linux 本地执行,以及外部 provider 是否确实收到失败的 transport request 均不在范围内。transport retry 可能重发同一请求,但 live history 最多只插入一次通知。
  • 破坏性变更 / 迁移说明:无。既有兼容 consume 方法继续保留,approval permission、Plan approval 和 tool execution 语义均未改变。

关联 Issues

关闭 #7745

关联 #7671(Problem 1)。这是 #7682 的后续改进,并与 #7673 互补。

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 26, 2026
@doudouOUC
doudouOUC marked this pull request as ready for review July 26, 2026 12:56
@doudouOUC
doudouOUC enabled auto-merge July 26, 2026 12:57
@doudouOUC doudouOUC self-assigned this Jul 26, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with a linked issue (#7745, type/bug, priority/P2). The one-shot notice merged in #7682 is consumed by a single pendingManualPlanExitNotice boolean during GeminiClient's UserQuery/Cron reminder assembly — that boundary genuinely misses tool-result continuations, ACP/daemon sends, steering, hooks, and interactive agents. The shared flag also lets one conversation consume the notice before another sees it. This is a real coverage gap in a shipped feature, not theoretical hardening.

Direction: aligned. Plan mode exit signaling is core to the plan-mode feature (#7671), and moving delivery to the chat history commit boundary is the natural chokepoint that covers all model-bound message types without per-protocol injection. No CHANGELOG reference needed — this fixes a gap in a recently merged feature.

Size: 222 production lines, 718 test lines, 100 docs lines across 16 files. Well under the 500-line threshold — no maintainer escalation needed. The test-to-production ratio (3.2:1) is strong.

Approach: the scope feels right. The event/cursor separation ({version, kind} event owned with the approval mode, {seenVersion} cursor lazily owned per conversation) is the minimal design that solves multi-conversation ownership. Rollback-on-setup-failure and reactive-compression preservation each address a concrete failure mode. I don't see a materially simpler path that covers the same guarantees. The diff is focused — every change serves the stated goal, no drive-by refactors.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有关联 issue(#7745type/bugpriority/P2)。#7682 合入的一次性通知通过 GeminiClient 的 UserQuery/Cron 提醒装配中的单一 pendingManualPlanExitNotice 布尔值消费——该边界确实遗漏了 tool-result 续轮、ACP/daemon 直发、steering、hook 和交互式 Agent。共享标志还允许一个会话在另一个会话看到通知之前先消费它。这是已发布功能中的真实覆盖缺口,不是理论性加固。

方向:对齐。Plan 模式退出信号是 plan-mode 功能的核心(#7671),将投递移到 chat history 提交边界是覆盖所有 model-bound 消息类型的自然咽喉点,无需逐协议注入。

规模:222 行生产代码、718 行测试代码、100 行文档,共 16 个文件。远低于 500 行阈值,无需维护者升级。测试与生产代码比率(3.2:1)很强。

方案:范围合理。event/cursor 分离({version, kind} 事件随 approval mode 拥有,{seenVersion} 游标按会话延迟拥有)是解决多会话所有权的最小设计。setup 失败回滚和 reactive compression 保留各自解决一个具体的失败模式。没有看到更简单的路径能覆盖相同的保证。diff 聚焦——每个改动都服务于既定目标,无顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the problem (notice only delivered on UserQuery/Cron paths, single boolean consumed by first conversation), I would move injection to GeminiChat.sendMessageStream at the history commit boundary, replace the boolean with a versioned event + per-conversation cursor, handle setup-failure rollback, and preserve the notice across reactive compression. The PR's approach matches this almost exactly.

Findings: no critical blockers or AGENTS.md violations found.

The implementation is clean and follows the established Config.prototype.method.call(this) pattern for prototype-inherited override objects. Specific observations:

  • config.ts — the event/cursor separation is well-structured. getManualPlanExitNoticeEventState() correctly copies the inherited event when a child Config first owns its approvalMode, isolating it from later parent events. restorePendingManualPlanExitNotice guards against stale restores with a four-condition check (version match, kind, mode, cursor). The backward-compatible consumePendingManualPlanExitNotice() wrapper is preserved.

  • geminiChat.ts — delivery at the history commit boundary (after compression/hard-rescue, before history.push) is the right linearization point. The notice is appended as the final text part, preserving function-response ordering. Setup-failure rollback restores the claim correctly. The reactive compression recovery uses includes(noticeText) to detect whether the notice survived — acceptable since the text is generated by the same code and won't appear by accident.

  • client.ts — cleanly removes the old injection path and enables notices on every main chat via startChat.

  • InProcessBackend.ts / agent.ts — both copy the event state when creating approval-mode overrides, consistent with the existing approvalMode/prePlanMode mirror pattern.

  • agent-core.ts — enables notices only for interactive: true chats, correctly excluding headless agents.

  • prompts.ts — the reworded notice text is clearer ("The approval mode changed outside the approved exit_plan_mode flow" vs the old "The user has manually switched out of plan mode"). Tests updated to match.

Test coverage is comprehensive: 718 lines covering transition semantics, inherited event ownership, independent cursors, stale restore, opt-in delivery, part ordering, setup rollback, provider retry, reactive compression (both model-tail and user-tail), chat recreation, and agent isolation.

CI Test Evidence

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Classify PR ✅ success

macOS, Windows, and Integration checks are skipped — this is a fork PR limitation, not a PR defect. The Ubuntu unit suite passed. Not verified: cross-platform behavior (the PR author also notes macOS-only local testing).

中文说明

代码审查

独立方案: 给定问题(通知仅在 UserQuery/Cron 路径投递,单一布尔值被第一个会话消费),我会将注入移到 GeminiChat.sendMessageStream 的 history 提交边界,用版本化事件 + 每会话游标替换布尔值,处理 setup 失败回滚,并在 reactive compression 中保留通知。PR 的方案与此几乎完全一致。

发现: 未发现关键阻塞问题或 AGENTS.md 违规。

实现干净,遵循既有的 Config.prototype.method.call(this) 模式。config.ts 的 event/cursor 分离结构良好;geminiChat.ts 在 history 提交边界投递是正确的线性化点;client.ts 干净地移除了旧注入路径;Agent 相关文件正确复制事件状态并仅为交互式 chat 启用通知。通知文本改写更清晰。测试覆盖全面(718 行),涵盖转换语义、继承事件所有权、独立游标、过期恢复、投递、part 排序、回滚、重试、压缩和 Agent 隔离。

CI 测试证据

Ubuntu 单元测试通过。macOS、Windows 和集成测试被跳过——这是 fork PR 的限制,不是 PR 的缺陷。未验证:跨平台行为。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid fix for a real coverage gap; clean design, comprehensive tests, CI green on Ubuntu. Only non-blocking nit: cross-platform CI was skipped (fork limitation).

This is a well-executed fix. The problem is real — #7682 shipped a notice that only fires on two of the many model-bound paths, and the shared boolean creates a race between conversations. The event/cursor design is the minimal structure that solves both issues, and the implementation lands it cleanly at the chat history commit boundary. Every change in the diff earns its place: the rollback handles setup failure, the compression recovery handles reactive rebuilds, the agent isolation handles multi-conversation ownership. The 3.2:1 test-to-production ratio gives real confidence in the edge cases.

My independent proposal matched the PR's approach almost exactly, which is a good sign — there isn't a simpler path being missed. The code follows established Config prototype patterns, the notice text improvement is a bonus, and the backward-compatible wrapper means no downstream breakage.

If I had to maintain this in six months, the design doc (docs/design/2026-07-26-manual-plan-exit-notice-delivery.md) and the test names would tell me exactly what each piece does and why. That's the kind of PR you thank the author for.

中文说明

置信度:4/5 —— 对真实覆盖缺口的扎实修复;设计干净,测试全面,Ubuntu CI 通过。唯一非阻塞的小点:跨平台 CI 被跳过(fork 限制)。

这是一个执行良好的修复。问题是真实的——#7682 发布的通知只在许多 model-bound 路径中的两条上触发,共享布尔值在会话之间产生竞争。event/cursor 设计是解决两个问题的最小结构,实现在 chat history 提交边界干净地落地。diff 中的每个改动都有其价值:回滚处理 setup 失败,压缩恢复处理 reactive 重建,Agent 隔离处理多会话所有权。3.2:1 的测试与生产代码比率给了边界情况真正的信心。

我的独立方案与 PR 的方案几乎完全一致——没有更简单的路径被遗漏。代码遵循既有的 Config 原型模式,通知文本改进是额外的加分,向后兼容的包装意味着没有下游破坏。

Qwen Code · qwen3.8-max-preview

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

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

LGTM, looks ready to ship. ✅

@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. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.7-max via Qwen Code /review

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

PR Review: fix(core): reliably deliver manual plan-exit notices

Summary

This PR fixes a gap where manual plan-exit notices weren't reliably delivered across all conversation types. The previous implementation using a single global pendingManualPlanExitNotice boolean only covered UserQuery and Cron reminders, missing ACP/daemon direct sends, tool-result continuations, steering continuations, hooks, and interactive agents.

Architecture Assessment ✅

The redesign is well-considered:

  • Event vs Cursor separation: Separating the approval-mode event (ManualPlanExitNoticeEventState) from per-conversation cursors (ManualPlanExitNoticeCursorState) is the key insight. Each conversation can independently claim the same inherited event once, solving the "one conversation steals another's notice" problem.
  • Version-based tracking: Using a monotonically incrementing version number provides a clean mechanism for:
    • Detecting stale restore attempts
    • Preventing duplicate delivery
    • Supporting inheritance isolation when child configs copy event state on first mode ownership
  • Commit boundary delivery: Moving notice injection to the history commit point in GeminiChat.sendMessageStream is architecturally cleaner than protocol-specific injection paths.

Code Quality ✅

Strengths:

  1. Comprehensive rollback handling in the synchronous setup path
  2. Proper preservation of notice across reactive context-overflow compression
  3. Clean opt-in API (enableManualPlanExitNotices()) with explicit enablement for main chats and interactive agents only
  4. Backward compatibility maintained via consumePendingManualPlanExitNotice()

Small observations:

  • The inheritance pattern via Object.create(parent) and lazy state ownership is consistent with the existing codebase patterns
  • The notice text is clear and actionable for the model

Test Coverage ✅

Excellent coverage across:

  • State machine transitions (rapid Plan round trips, mode switches)
  • Inheritance semantics (parent/child isolation, cursor independence)
  • Rollback scenarios (setup failure, provider retry, reactive compression)
  • Agent integration (interactive vs headless, fork/speculation exclusion)
  • Edge cases (stale restore, replacement chat with same cursor)

The 379 new lines in geminiChat.test.ts thoroughly exercise the delivery semantics.

Security Considerations ✅

No security concerns identified:

  • The notice is purely informational to the model about mode state
  • No user data or credentials involved
  • No execution privilege escalation

Potential Minor Suggestions

  1. Documentation comment clarity (optional): In getManualPlanExitNoticeEventState(), the comment about lazy ownership could briefly explain why the inheritance copy happens (to isolate child from parent's subsequent events). The design doc covers this, but inline it might help future maintainers.

  2. Type safety: The prototype method calls like Config.prototype.getManualPlanExitNoticeEventState.call(this) are necessary for the inheritance pattern, but consider adding a brief comment explaining why direct calls would be insufficient.

CI Status

  • ✅ Test (ubuntu-latest, Node 22.x) passed
  • Tests on macOS/Windows were skipped (noted in PR body as partially validated)

Conclusion

This is a well-designed fix with thorough test coverage. The state machine approach cleanly solves the delivery reliability problem while maintaining backward compatibility and proper isolation semantics.

One minor suggestion for future consideration: the design doc mentions that "Notice persistence across process restarts" is out of scope. If this becomes a requirement later, the version-based state machine provides a clean foundation for adding persistence.

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 26, 2026
Merged via the queue into QwenLM:main with commit 06df241 Jul 26, 2026
85 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

@yiliang114

Copy link
Copy Markdown
Collaborator

⚠️ Failed to process this request. Please re-mention the bot to retry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Manual Plan-exit notice can be missed across live conversation paths

3 participants