Skip to content

fix(core): preserve active Todo context across tool turns - #7919

Merged
wenshao merged 12 commits into
QwenLM:mainfrom
yiliang114:cx/preserve-active-todo-context
Jul 30, 2026
Merged

fix(core): preserve active Todo context across tool turns#7919
wenshao merged 12 commits into
QwenLM:mainfrom
yiliang114:cx/preserve-active-todo-context

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

This change keeps the latest unfinished Todo list salient across tool turns. After a successful Todo update, Qwen Code retains a bounded prompt-scoped reminder and appends it after function responses in both the core and ACP loops. Mid-turn user input remains last so the user's newest direction keeps precedence.

Todo reminders are isolated by work-chain prompt ID, so cron jobs and background notifications cannot overwrite an ordinary user task. Retry, continue, and explicitly related automatic turns move the reminder to their new prompt ID, while session changes clear it. The existing experimental Todo Stop Guard remains unchanged and disabled by default.

Why it's needed

Today the complete Todo state is emphasized only in the immediate todo_write result. After several other tool calls, that state loses salience and the model can naturally end the turn while Todo items are still pending. Re-reading the persisted Todo file is not safe because it can outlive the work chain that created it, and changing stop semantics would turn a planning aid into a completion oracle.

This preserves the current task context before the stop decision without treating Todo state as proof that the task is complete or forcing an automatic retry.

Reviewer Test Plan

How to verify

  1. Start a multi-step task that creates a pending Todo and performs at least one additional tool call. Confirm the next model request contains the unfinished Todo reminder after the tool's function response.
  2. Complete every Todo and perform another tool call. Confirm the reminder is absent.
  3. Start an unrelated user prompt. Confirm the previous reminder is cleared.
  4. Retry or continue the interrupted task. Confirm its reminder follows the continued work chain.
  5. Run a cron or background-notification tool loop alongside an ordinary task. Confirm each loop reads only the reminder owned by its prompt ID.
  6. Queue mid-turn user input while a tool executes. Confirm the request order is function responses, Todo reminder, then the user's new input.

Evidence (Before & After)

N/A — model-context behavior with no UI change. Focused regressions cover Todo persistence, prompt ownership, Core and ACP injection order, retry/continue transfer, and the Todo Stop Guard remaining off by default.

Tested on

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

Environment (optional)

Local Node.js 22 workspace. npm run build, npm run typecheck, changed-file ESLint and Prettier, TodoWrite and Config tests, Core client and scheduler tests, non-interactive CLI tests, and focused ACP Session regressions all pass. A full ACP Session run reached 454 passing tests and one pre-existing 30-second mid-turn drain timeout; the affected ACP tests pass when run directly.

Risk & Scope

  • Main risk or tradeoff: active Todo reminders add a small amount of repeated context on tool-result turns; only unfinished items are included, the payload is capped at 4,000 characters, and prompt ownership bounds its lifetime.
  • Not validated / out of scope: changing natural-stop semantics, enabling or removing todoStopGuard, and guaranteeing that every model will always follow the reminder.
  • Breaking changes / migration notes: none.

Linked Issues

Related to #6945 and #7821.

中文说明

这个 PR 做了什么

这个改动让最新的未完成 Todo 列表在连续工具轮次中保持显著。todo_write 成功后,Qwen Code 会保留一段有大小上限、按 prompt 隔离的 reminder,并在 Core 和 ACP 循环中把它追加到 function response 之后。轮次中途到达的用户输入仍然放在最后,因此用户最新指令保持最高优先级。

Todo reminder 按工作链 prompt ID 隔离,因此 cron 任务和后台 notification 不会覆盖普通用户任务。retry、continue 和明确相关的自动轮次会把 reminder 转移到新的 prompt ID;session 切换会清理 reminder。现有实验性 Todo Stop Guard 不做修改,默认仍然关闭。

为什么需要

当前完整 Todo 状态只会在当次 todo_write 结果中被重点提示。经过若干其他工具调用后,这段状态的显著性会降低,模型可能在仍有 pending Todo 时自然结束轮次。直接重新读取持久化 Todo 文件并不安全,因为文件可能比创建它的工作链存活更久;而修改 stop 语义则会把规划辅助误当成完成判定器。

这个方案在模型做出停止决定之前持续保留当前任务上下文,但不会把 Todo 状态当作任务已经完成的证据,也不会强制自动重试。

Reviewer 测试计划

如何验证

  1. 启动一个会创建 pending Todo、并至少继续执行一次其他工具调用的多步骤任务。确认下一次模型请求在工具 function response 之后包含未完成 Todo reminder。
  2. 完成所有 Todo 后再执行一次工具调用。确认 reminder 不再出现。
  3. 启动一个无关的新用户 prompt。确认旧 reminder 已清理。
  4. retry 或 continue 被中断的任务。确认 reminder 跟随续接后的工作链。
  5. 在普通任务旁运行 cron 或后台 notification 工具循环。确认每个循环只读取自己 prompt ID 对应的 reminder。
  6. 工具执行期间加入一条中途用户输入。确认请求顺序是 function response、Todo reminder、用户新输入。

证据(Before & After)

N/A —— 这是模型上下文行为变化,没有 UI 改动。聚焦回归覆盖 Todo 持久化、prompt ownership、Core 与 ACP 注入顺序、retry/continue 转移,以及 Todo Stop Guard 默认仍关闭。

测试平台

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

环境(可选)

本地 Node.js 22 workspace。npm run buildnpm run typecheck、变更文件 ESLint 与 Prettier、TodoWrite 与 Config 测试、Core client 与 scheduler 测试、non-interactive CLI 测试和 ACP Session 聚焦回归均通过。ACP Session 全量运行有 454 个测试通过,另有一个既有的 mid-turn drain 用例在 30 秒超时;本次影响到的 ACP 测试单独运行均通过。

风险与范围

  • 主要风险或取舍:active Todo reminder 会在 tool-result 轮次加入少量重复上下文;内容只包含未完成项、上限为 4,000 字符,并通过 prompt ownership 限制生命周期。
  • 未验证 / 不在本次范围:修改自然停止语义、开启或移除 todoStopGuard,以及保证所有模型始终遵循 reminder。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

#6945#7821 相关。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 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

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: this is an observed behavioral pattern, not a theoretical concern — models do lose todo salience after several tool turns, and the two merged predecessors (#6945, #7821) confirm this is a known reliability gap. The PR describes the problem clearly and the design doc lays out the reasoning well.

Direction: aligned. Todo context persistence is a direct extension of the existing todo management feature, and the CHANGELOG shows sustained investment in this area (todo stop guard, todo panel, floating todos). This sits squarely within qwen-code's task-management mission.

Size: core paths touched (config.ts, client.ts, coreToolScheduler.ts, todoWrite.ts, plus registries and scheduler plumbing) — 468 production logic lines, 802 test lines, 43 docs lines. Well under the 500-line threshold. Not applicable for maintainer escalation.

Approach: the prompt-ID isolation adds real complexity, but it's justified — the ACP/daemon context runs concurrent work chains (user prompts, cron, notifications), and without isolation a cron turn could clobber a user task's reminder. The every-3rd-turn cadence (ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3) is a smart addition since the previous review — it prevents linear context growth from per-turn injection. The todoWorkChainContext AsyncLocalStorage propagation to background tasks/shells/monitors is necessary plumbing for the isolation to work end-to-end. One minor observation: the todoStopGuardBackgroundBaseline simplification (always capturing the baseline instead of conditionally) is a drive-by change unrelated to the feature — small and harmless, but worth noting.

Risk: packages/cli/src/acp-integration/session/Session.ts matches the high-risk acp-integration path (correlated with post-merge reverts in this repo's history). Full Stage 2 enrichment and CI evidence required before approving. The author is a fork contributor without write access — a maintainer can trigger @qwen-code /verify as a sponsored run if real-scenario verification is desired (that run carries a pre-execution risk screen and full workspace wipe; read the resulting report with the same skepticism as the fork's CI logs).

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:这是一个已观测到的行为模式,而非理论性担忧——模型在多次工具调用后确实会失去对 todo 的显著性关注,两个已合并的前置 PR(#6945#7821)也确认了这是一个已知的可靠性缺口。PR 描述清晰,设计文档也很好地阐述了推理过程。

方向:对齐。Todo 上下文持久化是现有 todo 管理功能的直接扩展,CHANGELOG 显示该领域有持续投入(todo stop guard、todo panel、floating todos)。完全在 qwen-code 任务管理使命范围内。

规模:触及核心路径(config.tsclient.tscoreToolScheduler.tstodoWrite.ts,以及注册表和调度器管道)——468 行生产逻辑、802 行测试、43 行文档。远低于 500 行阈值。不需要维护者升级。

方案:prompt-ID 隔离增加了真实复杂度,但这是合理的——ACP/daemon 上下文运行并发工作链(用户 prompt、cron、notification),没有隔离的话 cron 轮次可能覆盖用户任务的 reminder。每 3 轮注入一次的节奏(ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3)是自上次审查以来的聪明改进——防止逐轮注入导致上下文线性增长。todoWorkChainContext AsyncLocalStorage 向后台任务/shell/monitor 的传播是隔离端到端工作所需的管道改动。一个小观察:todoStopGuardBackgroundBaseline 的简化(始终捕获基线而非条件性地)是一个与功能无关的顺手改动——小且无害,但值得注意。

风险:packages/cli/src/acp-integration/session/Session.ts 匹配高风险 acp-integration 路径(与本仓库历史中的合并后回退相关)。批准前需要完整的 Stage 2 充实内容和 CI 证据。作者是 fork 贡献者,没有写权限——如需真实场景验证,维护者可触发 @qwen-code /verify 作为赞助运行(该运行带有预执行风险筛查和完整工作区清除;请以与 fork CI 日志相同的怀疑态度阅读生成的报告)。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 474f122bbb9a36001e589bba764143e6510d9fbb · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: I would have stored the unfinished todo list as a single string on Config, appended it after function responses in both the core and ACP loops, and cleared it on new user prompts or completion. No prompt-ID isolation — just a simple field.

Comparison with the PR: the PR's approach matches mine in the core mechanism (store → inject → clear) but adds three layers of justified complexity:

  1. Prompt-ID-keyed isolation via a Map<string, string> on Config — necessary because the ACP/daemon context runs concurrent work chains (user prompts, cron jobs, background notifications), and without isolation a cron turn's todo_write would overwrite a user task's reminder.
  2. Every-3rd-turn cadence (ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3) — new since the last review pass. Each injected copy lands permanently in chat history, so per-turn injection would grow context linearly. The force parameter for turn-start injections (retry, related automatic) always fires and resets the cadence. Well-designed.
  3. todoWorkChainContext AsyncLocalStorage — propagates work-chain ownership to background tasks, shells, monitors, and loop wakeups at registration time, so notifications carry their owning chain back to the session. Necessary for the isolation to work end-to-end.

No critical blockers found. Specific observations:

  • Config isolation pattern (getOwnActiveTodoReminders with hasOwnProperty.call) is consistent with existing patterns for approvalMode, memoryPressureMonitor, and fileReadCache in the same file. Child Configs created via Object.create(parent) get their own Map. ✓
  • promptIdContext and todoWorkChainContext reuse — extends the existing AsyncLocalStorage<string> (already used for shell context env vars) to tool execution via promptIdContext.run() and todoWorkChainContext.run() in coreToolScheduler.ts. Natural extension, no new abstraction. ✓
  • Injection order is correct in both loops: core (client.ts) places the reminder after function responses and before microcompact; ACP (Session.ts) places it between toolRun.parts and drained.parts (mid-turn user input stays last). ✓
  • escapeSystemReminderTags on the serialized todo JSON prevents todo content from injecting fake <system-reminder> tags. ✓
  • MAX_ACTIVE_TODO_CONTEXT_CHARS = 800 — reduced from the 4,000 mentioned in the PR description. Compact payload. ✓
  • Notification batching by work chainuseGeminiStream.ts and nonInteractiveCli.ts now split notification batches by todoWorkChainId, ensuring notifications from different work chains don't mix their reminders. ✓
  • endAutomaticActiveTodoWorkChain cleanup — called in finally blocks for cron and notification turns, and in the core client's finally for automatic turns that complete without tool calls. The hasToolCalls variable was correctly hoisted to the outer scope to support this check. ✓
  • enterWith in Session.ts #runToolCalls — the comment explains why enterWith (not run) is deliberate: background task/shell/monitor registration can occur in async continuations after runToolCalls resolves. Correct for the ACP path. ✓
  • Drive-by change: the todoStopGuardBackgroundBaseline initialization is simplified to always capture the baseline instead of conditionally. Small, harmless, but unrelated to the feature.
Files changed (29 of 29 shown)
File What changed
docs/design/active-todo-context.md New design doc explaining the problem, isolation model, cadence, and verification criteria
packages/core/src/config/config.ts Adds prompt-ID-keyed reminder Maps with get/set/take/start-work-chain/start-automatic/end-automatic methods, every-3rd-turn cadence, and session-clear hook
packages/core/src/config/config.test.ts Tests work-chain transfer, cadence, automatic isolation, child-Config inheritance, and session clearing
packages/core/src/core/client.ts Manages work chain on UserQuery/Retry/Cron/Notification/Teammate turns; injects reminder after function responses and at turn start; ends automatic chains in finally
packages/core/src/core/client.test.ts Tests reminder injection order, work-chain lifecycle, retry continuation, related notification inheritance, and automatic chain cleanup
packages/core/src/core/client-goal.test.ts Adds mock stubs for the new Config methods
packages/core/src/core/coreToolScheduler.ts Wraps tool execution in promptIdContext.run() and todoWorkChainContext.run() so tools and their background registrations read the correct chain
packages/core/src/core/coreToolScheduler.test.ts Asserts both ALS contexts propagate to tool execute() and survive approval confirmation
packages/core/src/tools/todoWrite.ts After writing todos, serializes unfinished items (capped at 800 chars) and stores the reminder via Config
packages/core/src/tools/todoWrite.test.ts Tests reminder content, truncation, clearing on completion, and no-prompt-id skip
packages/core/src/utils/promptIdContext.ts Adds todoWorkChainContext AsyncLocalStorage export
packages/core/src/agents/background-tasks.ts Captures todoWorkChainId at registration; includes it in notification meta
packages/core/src/agents/background-tasks.test.ts Tests work-chain capture at registration
packages/core/src/agents/tasks/types.ts Adds todoWorkChainId field to TaskBase
packages/core/src/services/backgroundShellRegistry.ts Captures todoWorkChainId at registration; includes it in notification meta
packages/core/src/services/backgroundShellRegistry.test.ts Tests work-chain capture at registration
packages/core/src/services/monitorRegistry.ts Captures todoWorkChainId at registration; includes it in event and terminal notification meta
packages/core/src/services/monitorRegistry.test.ts Tests work-chain capture at registration
packages/core/src/services/cronScheduler.ts Threads todoWorkChainId through wakeup scheduling and CronJob
packages/core/src/tools/loop-wakeup.ts Passes todoWorkChainContext to scheduleWakeup
packages/core/src/tools/loop-wakeup.test.ts Tests work-chain propagation to scheduled wakeup
packages/cli/src/acp-integration/session/Session.ts Manages work chain on prompt/cron/notification; injects reminder in buildNextMessageAfterToolRun and at turn start; adds enterWith for tool context; threads todoWorkChainId through queues
packages/cli/src/acp-integration/session/Session.test.ts Tests work-chain clearing on ordinary prompt, continuation on retry, related-notification inheritance, guard-lineage non-inference, and injection order with mid-turn input
packages/cli/src/acp-integration/session/Session.review-lease.test.ts Adds mock stubs for the new Config methods
packages/cli/src/acp-integration/session/Session.worktree.test.ts Adds mock stubs for the new Config methods
packages/cli/src/nonInteractiveCli.ts Derives per-turn prompt IDs for teammate and automatic turns; splits notification batches by todoWorkChainId
packages/cli/src/nonInteractiveCli.test.ts Updates prompt-ID expectations; adds work-chain batch-splitting test
packages/cli/src/ui/hooks/useGeminiStream.ts Threads todoWorkChainId through notification queue; splits batches by chain
packages/cli/src/ui/hooks/useGeminiStream.test.tsx Tests work-chain batch splitting for monitor notifications

Testing

This is a CI run — no local real-scenario testing. CI on the reviewed commit 474f122bbb9a36001e589bba764143e6510d9fbb is fully green:

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
macos-latest / Java 21 ✅ success
windows-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
review-pr ✅ success

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

All completed checks passed. macOS/Windows Node tests and integration tests were skipped (not required for this PR path).

Not verified: real-scenario TUI behavior (model-context change with no UI surface — N/A for tmux capture). Sandboxed verification would settle the behavioural claim: @qwen-code /verify — that the every-3rd-turn cadence actually suppresses injection on turns 1-2 and fires on turn 3 is not observable from the diff alone, and this PR's suite passes with ACTIVE_TODO_REMINDER_REFRESH_TURNS set to 1. This is a sponsored run (fork author lacks write access); the maintainer's @qwen-code /verify comment approves the head it was written against, and that run carries a pre-execution risk screen and full workspace wipe — read the resulting report with the same skepticism as the fork's CI logs.

中文说明

代码审查

独立方案: 我会在 Config 上用一个简单字符串存储未完成 todo 列表,在 core 和 ACP 循环中把它追加到 function response 之后,并在新用户 prompt 或完成时清除。不做 prompt-ID 隔离。

与 PR 的比较: PR 的核心机制(存储→注入→清除)与我的方案一致,但增加了三层合理的复杂度:

  1. 基于 prompt-ID 的隔离(Config 上的 Map<string, string>)——必要,因为 ACP/daemon 上下文运行并发工作链。
  2. 每 3 轮注入一次的节奏——新改进,防止上下文线性增长。
  3. todoWorkChainContext AsyncLocalStorage——向后台任务/shell/monitor 传播工作链所有权。

未发现关键阻塞问题。注入顺序在两个循环中都正确,escapeSystemReminderTags 防止注入伪造标签,MAX_ACTIVE_TODO_CONTEXT_CHARS = 800 限制了负载大小,通知批处理按工作链分割,自动工作链在 finally 块中正确清理。

测试

CI 运行——无本地真实场景测试。被审查提交 474f122bbb9a36001e589bba764143e6510d9fbb 的 CI 全部通过。

未验证:真实场景 TUI 行为(模型上下文变更,无 UI 界面——不适用)。沙箱验证可以确认行为声明:@qwen-code /verify——每 3 轮节奏是否真正在第 1-2 轮抑制注入并在第 3 轮触发,仅从 diff 无法观察到。这是赞助运行(fork 作者无写权限)。

Qwen Code · qwen3.8-max-preview

Reviewed at 474f122bbb9a36001e589bba764143e6510d9fbb · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean review, well-justified design, CI fully green; only reservation is the inherent complexity of the acp-integration surface.

Stepping back: this PR solves a real salience problem — models do lose track of pending todos after several tool turns, and the two merged predecessors confirm the team already considers this a reliability gap. The prompt-ID isolation looked like over-engineering at first glance, but the ACP/daemon context genuinely runs concurrent work chains, and without it a cron todo_write would clobber a user task's reminder. The every-3rd-turn cadence is a welcome refinementince the last review — it directly addresses the linear context growth concern that per-turn injection would create.

The code follows established patterns (Config's hasOwnProperty isolation, the existing promptIdContext AsyncLocalStorage), the injection order is correct in both loops, and the test coverage is thorough — work-chain transfer, cadence counting, child-Config inheritance, truncation, injection ordering with mid-turn input, notification batch splitting by chain, and automatic chain cleanup in finally blocks. The todoWorkChainContext propagation to background tasks, shells, monitors, and loop wakeups is the kind of end-to-end plumbing that's easy to miss — good that it's covered with dedicated registration tests in each registry.

The design doc is a bonus that will help future maintainers understand why the isolation exists and what the cadence tradeoff is.

Minor nits (non-blocking): the todoStopGuardBackgroundBaseline simplification is a drive-by unrelated to the feature, and the as unknown as Config casts in tests are a small hygiene cost of adding methods to a large class. Neither warrants a change request.

CI is fully green on 474f122bbb9a36001e589bba764143e6510d9fbb — all workflow runs completed, no pending checks.

中文说明

置信度:4/5 —— 审查干净,设计合理,CI 全部通过;唯一的保留是 acp-integration 表面的固有复杂度。

退后一步看:这个 PR 解决了一个真实的显著性问题——模型在多次工具轮次后确实会失去对 pending todo 的跟踪,两个已合并的前置 PR 确认团队已经将此视为可靠性缺口。prompt-ID 隔离乍看像是过度工程,但 ACP/daemon 上下文确实运行并发工作链,没有它 cron 的 todo_write 会覆盖用户任务的 reminder。每 3 轮注入一次的节奏是自上次审查以来的受欢迎改进——直接解决了逐轮注入会造成的上下文线性增长问题。

代码遵循既有模式(Config 的 hasOwnProperty 隔离、现有的 promptIdContext AsyncLocalStorage),注入顺序在两个循环中都正确,测试覆盖全面——工作链转移、节奏计数、子 Config 继承、截断、带中途输入的注入排序、按链分割通知批处理、以及 finally 块中的自动链清理。todoWorkChainContext 向后台任务、shell、monitor 和 loop wakeup 的传播是容易遗漏的端到端管道——好在每个注册表都有专门的注册测试覆盖。

设计文档是帮助未来维护者理解隔离存在原因和节奏权衡的额外加分。

小问题(非阻塞):todoStopGuardBackgroundBaseline 简化是与功能无关的顺手改动,测试中的 as unknown as Config 转换是给大类添加方法的小卫生成本。两者都不需要修改请求。

CI 在 474f122bbb9a36001e589bba764143e6510d9fbb 上全部通过——所有工作流运行已完成,无待处理检查。

Qwen Code · qwen3.8-max-preview

Reviewed at 474f122bbb9a36001e589bba764143e6510d9fbb · re-run with @qwen-code /triage

@yiliang114
yiliang114 force-pushed the cx/preserve-active-todo-context branch from e9143e9 to 28ddaeb Compare July 28, 2026 09:40
@github-actions

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.

[Critical] Session.worktree.test.ts: missing startActiveTodoWorkChain mock on mockConfig — TypeError when session.prompt() reaches the new code path. Same fix as Session.test.ts line 574.

[Critical] Session.review-lease.test.ts: missing startActiveTodoWorkChain mock on mockConfig — same pattern, causes TypeError in all 4 tests (RL1-RL4).

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/core/client.ts
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/core/src/core/client.ts
@yiliang114
yiliang114 requested a review from doudouOUC July 28, 2026 11:38
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. Run review failed. See workflow logs for details. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@doudouOUC doudouOUC 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. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

中文说明

已审查。 建议见行内评论。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/tools/todoWrite.ts
Comment thread packages/cli/src/nonInteractiveCli.ts
doudouOUC
doudouOUC previously approved these changes Jul 28, 2026

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

Full review at 2d9f7f58dda2a64d258a2185ed44acc9853b710e with local verification (worktree checkout, typecheck, changed-file ESLint, and the focused test suites for config, todoWrite, client, coreToolScheduler, registries, nonInteractiveCli, and the ACP Session todo regressions — all passing). Approving.

Prior Criticals verified fixed at HEAD:

  • The core cron/notification branch now uses startAutomaticActiveTodoWorkChain (selective delete), so an automatic turn can no longer wipe the user's reminder via reminders.clear().
  • Both Session.worktree.test.ts and Session.review-lease.test.ts carry the six new config mocks; the suites pass locally.
  • The currentPromptId non-reset in the headless loop is correct as the author argued: the teammate turn registers owners[teammateId] → userOwner, tool-result turns of that chain resolve through the mapping, and the chain only ends (in the finally, when no tool calls remain) at exactly the point where the loop either exits or a new teammate turn refreshes the ID. There is no window where a stale ID reads the wrong key.

Independently checked beyond the existing threads:

  • Ownership lifecycle: endAutomaticActiveTodoWorkChain's reference check keeps the user-owned reminder alive across related automatic turns and releases isolated owners without leaks. The race between an ordinary prompt's owners.clear() and an in-flight automatic turn's finally is benign in both orderings (mutual no-op deletes).
  • Injection order: core appends after function responses (before microcompact sizing); ACP places the reminder between toolRun.parts and drained.parts, so mid-turn user input stays last. flatMapTextParts guarantees requestToSend is an array, so the new some/findIndex/splice calls are safe on all message types.
  • The hasToolCalls hoist is behavior-preserving — its only new reader is the todo-chain finally, and the existing prefetch check at the bottom of the try sees the same values as before.
  • Every added todoWorkChainId field has paired write/read sites across CronJob, the three registries' notification metas, SendMessageOptions, and the ACP/headless queue items — no dead switches. Child Configs (Object.create) get isolated reminder maps, covered by tests.
  • CI is green on this commit (main unit suite, daemon E2E, web-shell E2E).

The two unresolved Suggestions (missing tests for the !promptId fallback and for batch-splitting by work chain) are worthwhile follow-ups but non-blocking per the project's review rules.

中文说明

2d9f7f58 上完成全面审查并本地验证(worktree 检出、typecheck、变更文件 ESLint、config/todoWrite/client/coreToolScheduler/registries/nonInteractiveCli/ACP Session todo 回归测试全部通过)。批准。

已确认此前 Critical 均在 HEAD 修复:core 的 cron/notification 分支改用选择性删除的 startAutomaticActiveTodoWorkChain;两个 Session 测试文件补齐了 mock;headless 循环不重置 currentPromptId 的做法经验证正确——teammate 轮建立 owner 映射,该链的 tool-result 轮通过映射解析,链结束时机与循环退出/新 teammate 轮刷新 ID 的时机吻合,不存在读错 key 的窗口。

在既有评论之外独立核查:所有权生命周期(引用计数保证用户 reminder 不被自动轮误删、清理竞态双向安全);注入顺序(core 在 function response 后,ACP 在 mid-turn 用户输入之前);hasToolCalls 提升不改变既有行为;所有新增 todoWorkChainId 字段写读点配对,无死开关;子 Config reminder 隔离有测试覆盖;该提交 CI 全绿。

两个未解决的 Suggestion(测试补充)值得跟进但不阻塞合并。

@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 — no blockers. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

中文说明

已审查——无阻断问题。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Local runtime verification (maintainer review aid)

I built this PR locally and drove the real CLI bundle against a mock OpenAI-compatible provider that records every outbound /v1/chat/completions body, so the assertions below are on the actual on-the-wire payload rather than on unit-test doubles.

Setup2d9f7f58d (PR head) vs a68c4cb (merge base), Linux / Node 22.22.2, isolated HOME, node dist/cli.js built from source (build --cli-only + npm run bundle). Three drivers: headless -p … --approval-mode yolo, the interactive TUI in tmux, and a real --acp stdio client (ClientSideConnection) that answers craft/drainMidTurnQueue to inject mid-turn input deterministically. The BEFORE arm is the same harness against a bundle with all 14 production files reverted to the merge base.

Result against the PR's own reviewer test plan

# Claim Verdict Evidence
1 Reminder appears after the function response on later tool turns todo_write (3 unfinished) → 2 shell calls: reminder appended after every tool result (msgs 4, 7, 10). BEFORE: zero occurrences in all 4 requests
2 All todos completed → reminder absent after the all-completed write, the next two tool-result requests append nothing new
3 New unrelated user prompt clears it TUI turn 2 (SCEN_TB) tool-result request carries no new reminder; only turn 1's historical copies remain
4 Retry / continue follows the work chain ✅ (unit level) compiled-Config probe + Session.test.ts; not driven through a real retry client
5 Cron / background loops read only their own reminder cron turn (real cron_create, fired between turns) → no reminder; background agent notification from the same task → reminder inherited
6 Order is function responses → reminder → user's new input ⚠️ ACP yes, interactive CLI no see finding 1
4,000-char cap + <system-reminder> escaping 26-item list ⇒ payload sliced to exactly 4,000 chars + [truncated]; injected </system-reminder> emitted as <\/system-reminder>, injected opening tag XML-escaped

Same prompt, same mock, same tool sequence — the final model request before and after the PR.

Isolation: [5.0] is the historical copy from the user's turn in both runs. The distinguishing row is [7.0] — the notification turn gets a fresh reminder ahead of its payload, the cron turn gets none.

Finding 1 — the "user input last" invariant holds in ACP but not in the interactive CLI

The ACP path builds [...toolRun.parts, reminder, ...drained.parts] (Session.ts:4595), and Session.test.ts pins that order (reversing the two lines fails injects drained mid-turn user messages with tool responses).

The interactive CLI reaches the same situation by a different route: useGeminiStream.ts:3677 pushes the drained mid-turn parts onto responsesToSend before submitting the ToolResult turn, and client.ts:2600 then appends the reminder at the very end. Typing a message while a tool runs therefore produces function response → user's message → todo reminder, i.e. the reminder is the last thing the model reads:

Both panels are real captures (ACP client vs. typing into the TUI during a 20 s shell call). This is the case the PR explicitly designed against — "the user's newest direction keeps precedence" — so it's worth closing before merge. The Retry/Cron/Notification branch a few lines above (client.ts:2560) already has exactly the right helper: it locates the first non-functionResponse part and splices there. Reusing that in the ToolResult branch instead of [...requestToSend, activeTodoReminder] would make both loops agree and cost one small edit.

Finding 2 — every injected copy is retained, so the cost is linear in tool turns, not bounded by 4,000 chars

The 4,000-char cap bounds a single injection. Each injected reminder becomes a user message in history and is re-sent on every subsequent turn, and nothing dedupes or drops the older copies. With a maxed-out list (26 unfinished items):

After 7 tool turns the duplicated reminders are 29.5 KB — 37.9 % of the whole request payload, growing +4,218 chars per tool turn. A 40-tool-call task would carry ~170 KB of near-identical text. They are plain user messages, so the tool-result size-clear path in microcompactHistoryBeforeSend doesn't reclaim them.

Related and cheaper to see: stale copies outlive their truth. In the completion scenario, after the final all-completed write the history still contains two messages asserting "the current task still has unfinished todo items". Only the newest copy is ever accurate.

Neither of these is a regression (main sends no reminder at all), so I'd call both non-blocking, but dropping the previous copy when a new one is injected — or skipping injection when the list hasn't changed since the last one — would keep the win and remove most of the cost.

Tests and mutation teeth

  • packages/cli: nonInteractiveCli, Session, Session.review-lease, Session.worktree562 passed, 1 skipped, no timeout on the mid-turn drain cases here.
  • packages/core: todoWrite, config, coreToolScheduler, background-tasks, backgroundShellRegistry, monitorRegistry, loop-wakeup1068 passed.
  • packages/core/src/core/client.test.ts fails to collect in my checkout (No "logStartSession" export is defined on the "../telemetry/loggers.js" mock). It fails identically at the merge base, so it is a local environment artifact, not caused by this PR — but it does mean the 120 new lines of client-loop assertions did not execute here; CI is authoritative for those.
  • Mutation testing — 4/4 caught by the PR's own suites: dropping the promptIdContext store read in todoWrite (4 failures), removing continuedFrom inheritance in startAutomaticActiveTodoWorkChain (1), unwrapping the non-shell promptIdContext.run/todoWorkChainContext.run in coreToolScheduler (1), and swapping the reminder past the drained parts in Session.ts (1).
  • A 13-check probe driving the compiled Config ownership methods directly (isolation, inheritance, release, retry transfer, fresh-prompt clear) passes 13/13.

Not covered

Retry/continue through a real daemon client, teammate turns, session-change clearing, Windows/macOS. Test (ubuntu-latest, Node 22.x) was still pending when I wrote this.

Verdict

The mechanism works as described and the isolation design earns its complexity — the cron-vs-background-agent contrast above is exactly the case a single global field would have got wrong. Finding 1 is a real gap against a stated invariant and is a small fix; finding 2 is a cost worth a follow-up. Neither blocks the behavior this PR is buying.

中文版本

本地运行时验证(供合并参考)

我在本地构建了该 PR,并用真实 CLI bundle 对接一个会记录每次 /v1/chat/completions 请求体的 mock OpenAI 服务,因此下面的结论都基于真实的线上(on-the-wire)负载,而不是单测替身。

环境 —— 2d9f7f58d(PR head)对比 a68c4cb(merge base),Linux / Node 22.22.2,隔离 HOME,从源码构建 node dist/cli.js。三条驱动路径:无头 -p … --approval-mode yolo、tmux 中的交互式 TUI,以及真实 --acp stdio 客户端(ClientSideConnection,通过应答 craft/drainMidTurnQueue 确定性地注入轮次中途输入)。BEFORE 组是同一套 harness,跑在把全部 14 个生产文件回退到 merge base 后重新打包的 bundle 上。

对照 PR 自带的 Reviewer 测试计划

# 结论点 判定 证据
1 后续工具轮次在 function response 之后出现 reminder todo_write(3 项未完成)→ 2 次 shell 调用:每次工具结果之后都追加了 reminder(消息 4、7、10)。BEFORE:4 个请求中一次都没有
2 全部 todo 完成后 reminder 消失 全部标记 completed 之后,接下来两个 tool-result 请求不再追加
3 新的无关用户 prompt 清理旧状态 TUI 第 2 轮(SCEN_TB)的 tool-result 请求没有新 reminder,只剩第 1 轮的历史副本
4 retry / continue 跟随工作链 ✅(单元层面) 编译后 Config 探针 + Session.test.ts;未通过真实 retry 客户端端到端验证
5 cron / 后台循环只读取自己 prompt ID 的 reminder cron(真实 cron_create,在两轮之间触发)→ 无 reminder;同一任务启动的后台 agent 通知 → 继承到 reminder
6 顺序为 function response → reminder → 用户新输入 ⚠️ ACP 成立,交互式 CLI 不成立 见问题 1
4,000 字符上限 + <system-reminder> 转义 26 项列表 ⇒ 负载精确截断到 4,000 字符并带 [truncated];注入的 </system-reminder> 输出为 <\/system-reminder>,注入的开标签被 XML 转义

问题 1 —— "用户输入最后"的不变式在 ACP 成立,在交互式 CLI 不成立

ACP 路径构造的是 [...toolRun.parts, reminder, ...drained.parts]Session.ts:4595),并且 Session.test.ts 锁住了这个顺序(把两行对调会让 injects drained mid-turn user messages with tool responses 失败)。

交互式 CLI 走的是另一条路:useGeminiStream.ts:3677 在提交 ToolResult 轮次之前就把 drain 出来的中途输入 push 进 responsesToSend,然后 client.ts:2600 把 reminder 追加到最末尾。于是在工具执行期间输入消息,实际顺序是 function response → 用户消息 → todo reminder,reminder 成了模型最后读到的内容。

两侧面板都是真实抓包(ACP 客户端 vs 在 TUI 里于 20 秒 shell 调用期间输入)。这恰好是 PR 明确想避免的场景("用户最新指令保持最高优先级"),建议合并前修掉。上方几行的 Retry/Cron/Notification 分支(client.ts:2560)已经有现成写法:找到第一个非 functionResponse 的 part 再 splice 进去。在 ToolResult 分支里复用它、替换掉 [...requestToSend, activeTodoReminder],就能让两条循环一致,改动很小。

问题 2 —— 每次注入的副本都会留存,成本随工具轮次线性增长,而非被 4,000 字符封顶

4,000 字符上限约束的是单次注入。每次注入的 reminder 都会成为历史里的一条 user 消息并在后续每一轮重发,且没有任何去重或淘汰。以打满的列表(26 项未完成)为例:7 个工具轮次之后,重复的 reminder 合计 29.5 KB,占整个请求负载的 37.9%,每个工具轮次增加 4,218 字符。一个包含 40 次工具调用的任务大约会携带 170 KB 近乎重复的文本。它们是普通 user 消息,因此 microcompactHistoryBeforeSend 里针对工具结果的 size-clear 路径回收不了。

一个相关且更直观的现象:过期副本会比事实活得更久。在"全部完成"场景里,最后一次全 completed 写入之后,历史中仍留有两条声称"当前任务仍有未完成 todo"的消息,只有最新那一条才是准确的。

这两点都不是回归(main 根本不发 reminder),因此我认为都不阻塞合并;但在注入新副本时丢弃旧副本,或在列表未变化时跳过注入,可以保住收益并消除大部分成本。

测试与变异(mutation)验证

  • packages/clinonInteractiveCliSessionSession.review-leaseSession.worktree —— 562 通过,1 跳过,本地没有出现 mid-turn drain 超时。
  • packages/coretodoWriteconfigcoreToolSchedulerbackground-tasksbackgroundShellRegistrymonitorRegistryloop-wakeup —— 1068 通过
  • packages/core/src/core/client.test.ts 在我的环境里无法收集(No "logStartSession" export is defined on the "../telemetry/loggers.js" mock)。在 merge base 上同样失败,所以是本地环境问题,不是本 PR 引入;但这也意味着新增的 120 行 client 循环断言在我这里没有执行,这部分以 CI 为准。
  • 变异测试 —— PR 自带用例 4/4 全部捕获:去掉 todoWrite 里的 promptIdContext 读取(4 个失败)、去掉 startAutomaticActiveTodoWorkChaincontinuedFrom 继承(1 个)、拆掉 coreToolScheduler 中非 shell 分支的 promptIdContext.run/todoWorkChainContext.run 包裹(1 个)、把 Session.ts 里的 reminder 挪到 drained parts 之后(1 个)。
  • 另有一个直接驱动编译后 Config ownership 方法的 13 项探针(隔离、继承、释放、retry 转移、新 prompt 清理),13/13 通过。

未覆盖

真实 daemon 客户端上的 retry/continue、teammate 轮次、session 切换清理、Windows/macOS。撰写本评论时 Test (ubuntu-latest, Node 22.x) 仍在运行中。

结论

机制与描述一致,prompt ID 隔离的复杂度是值得的 —— 上面 cron 与后台 agent 的对比正是单一全局字段会做错的场景。问题 1 是与既定不变式之间的真实缺口且修复成本很低;问题 2 是值得后续跟进的开销。两者都不阻塞本 PR 带来的收益。

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

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /verify

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: merge-ready (agent verdict) - workflow run
沙箱验证:可合入(agent 判定)

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.

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

Scripted assertions: 33 passed · 0 failed · 33 total

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

Verification report (report.md)

# PR 7919 — Deep Verification: preserve active Todo context across tool turns

**Verdict: merge-ready** — 33/33 scripted assertions passed, 0 failures. No blocking findings.
Verified head: `629e603ddbda598e331ef85c793c429e93dc8bbd` (`HEAD^2`). Merge commit `78bd1b2e5`, base tip `HEAD^1 = bfd4c8e51`.

## Scope

- **Central claim:** after a successful `todo_write` that leaves unfinished items, the *next* model request (the tool-result turn) carries a bounded todo reminder appended **after** the function response; it is absent on the originating user query and when there is nothing unfinished.
- **Secondary claim 1:** reminders are isolated by work-chain prompt ID — a new ordinary prompt clears stale state, retry/continue retains it, independent cron/notification turns use an isolated owner that is released when the turn ends, related automatic turns inherit, and child `Config`s do not share state.
- **Secondary claim 2 (ACP):** the ACP loop injects the reminder in the order function-responses → reminder → drained mid-turn user input (mid-turn input stays last).

Out of scope by design: `todoStopGuard` semantics (PR leaves it disabled/unchanged), guaranteeing model compliance with the reminder.

## Central claim — A/B load-bearing proof (wire oracle)

Drove the **real bundled CLI** (`node dist/cli.js`, the esbuild bundle built at HEAD) headless against a zero-dependency loopback OpenAI mock that scripted: request 1 → a `todo_write` tool call with two unfinished items; request 2 (after the tool result) → final text. The mock wrote every request body to disk; assertions ran over those captured bytes. The control differs from head by **exactly one hunk**: `packages/core/src/tools/todoWrite.ts` reverted to `HEAD^1` (the reminder-generation block), then `packages/core` rebuilt and rebundled. The control bundle was confirmed to still contain the client + ACP injection code (`getActiveTodoReminder` present), so it differs only by the generation source.

| Cell | Build | req-000 (user query) | req-001 (tool-result turn) | `todo_write` ran? |
|------|-------|----------------------|----------------------------|-------------------|
| **head** | bundle @ HEAD | reminder **absent** | reminder **present**, message order `system → user → assistant(todo_write) → tool → user(reminder)` | yes |
| **control** | bundle, `todoWrite.ts`→base | reminder **absent** | reminder **absent** (`system → user → assistant(todo_write) → tool`) | yes |

Both cells executed `todo_write` successfully ("Todos have been modified successfully" present in both req-001), so the scenario is identical; only the reminder generation differs. The reminder flips **present (head) → absent (control)**. In the head req-001 the reminder rides in a separate `user` message at index 4, strictly after the `role:"tool"` function response at index 3 — exactly the claimed "after the tool's function response." 10/10 scripted A/B assertions passed (`wire-ab.assert.mjs`).

## Secondary claim 1 — Config ownership (mock-free)

`config-ownership.harness.mjs` imports the **real compiled `Config`** from `packages/core/dist` and exercises its real methods (no stubs) on bare `Object.create(Config.prototype)` instances — the ownership methods are self-contained over two lazily-initialised Maps. **23/23 assertions passed**, covering: set/get round-trip; `undefined` clears (completed-list path); a new ordinary chain clears stale reminders; retry/continue retains under the new prompt ID; continuing one chain drops unrelated reminders; independent automatic turns are isolated and released on `endAutomaticActiveTodoWorkChain`; related automatic turns inherit the foreground owner; a terminal automatic turn releases its own unreferenced reminder; child `Config` (`Object.create(parent)`) does not share reminder state in either direction (the `hasOwnProperty` guard); and the fallback-owner default.

## Secondary claim 2 — ACP injection order

Verified by code inspection of `Session.#buildNextMessageAfterToolRun` (`parts = [...toolRun.parts, ...(reminder ? [{text:reminder}] : []), ...drained.parts]` — reminder sits between the tool-response parts and the drained mid-turn user input, so mid-turn input stays last) plus the ACP gate below. Not re-driven by my own wire harness (see Not covered).

## Vacuity check (central test is not vacuous)

Mutated the client's ToolResult injection in source (`if (activeTodoReminder)` → `if (false && activeTodoReminder)`, interface preserved) and ran the central new test `client.test.ts › carries active todos after tool results and clears them for new work`. It **failed on the intended assertion**: `AssertionError: expected -1 to be greater than 0` at `expect(request.indexOf(reminder)).toBeGreaterThan(functionResponseIndex)` — the reminder is absent (`-1`) precisely because the injection was removed. The test is pinned by the production code. Source restored afterward (tree clean).

## Targeted gates (at the verified merge commit HEAD)

| Gate | Result |
|------|--------|
| `packages/core` — `todoWrite`, `config`, `client`, `coreToolScheduler` tests | **1118/1118 pass** (4 files) |
| `packages/cli` — `nonInteractiveCli.test.ts` | **91 pass, 1 skipped** |
| `packages/cli` — `acp-integration/session/Session.test.ts` | **460/460 pass** (the PR-noted 30s mid-turn drain test passes within the suite at this head) |
| `npm run typecheck` (all workspaces) | **exit 0** (28s) |

The checkout is the merge-ref (`HEAD` = PR head merged into the current `main` tip `HEAD^1`), so these gates and the A/B already exercise the **merged** tree, not just the PR in isolation.

## Findings

None blocking. One inspected-but-not-proven item, recorded for completeness (not a finding — no defect demonstrated):

- In `client.ts`, `hasToolCalls` is assigned at the steered/hook/continue return sites but not on the final fall-through `return turn`. I considered whether an automatic turn completing via that main path with pending tool calls could release ownership early. The PR's `client.test.ts › keeps automatic Todo ownership through its tool-result turns` exercises exactly this lifecycle (Notification turn yields a tool call → `endAutomaticActiveTodoWorkChain` not called until the terminal ToolResult turn) and passes; I did not independently trace every generator return path beyond that.

Positive note: reminder content is serialized through `escapeSystemReminderTags` and capped at 4,000 chars (`[truncated]` marker), bounding prompt-injection surface and size; both behaviors are covered by passing `todoWrite.test.ts` cases.

## Not covered

- ACP injection was not driven by my own end-to-end wire harness; it is covered by the 460-test `Session.test.ts` gate plus code inspection of the injection order.
- Per-commit attribution: the checkout is shallow (depth 2) — only 1 of the 7 commits in the metadata is reachable (`git rev-list HEAD^1..HEAD^2` = 1 vs 7 recorded). Verified the aggregate `HEAD^1..HEAD` diff only.
- Retry/continue transfer and cron/notification isolation were verified at the real-`Config`-method level and via the core client test, not via a full multi-turn ACP wire scenario.
- Did not plant a violation to prove the `typecheck` gate live; it ran clean (exit 0) across all workspaces in 28s, which indicates real work but is not a planted-violation proof.
- `escapeSystemReminderTags` injection-resistance was not fuzzed with adversarial todo content.
- `todoStopGuard` interaction (explicitly out of scope per the PR; unchanged and disabled by default).

## Methodology

CI verify job: merge-ref checkout at depth 2, `npm ci` + `npm run build` already complete; no GitHub token (metadata read from `$QWEN_VERIFY_CONTEXT`). Wire harness: a zero-dependency loopback OpenAI mock (`mock-server.mjs`) scripted the todo_write-then-stop scenario and recorded each request body; `run-cell.sh` drove `node dist/cli.js` headless (`--auth-type openai --openai-base-url 127.0.0.1 --approval-mode yolo`) in an isolated `QWEN_RUNTIME_DIR`/project dir for the head and control cells. Control: `git show HEAD^1:…/todoWrite.ts` over the source, `npm run build -w packages/core`, `npm run bundle`; verified the control retained client+ACP injection and differed only by the generation hunk; source and head bundle restored afterward (tree clean). Config harness imported the real compiled `Config`. Gates ran via workspace `vitest`. Raw request bodies, server logs, CLI output, and harness results live under `raw/` (`head/`, `control/`, `config-ownership.json`, `wire-ab.json`).

<details>
<summary>中文摘要</summary>

**结论:merge-ready** — 33/33 脚本化断言通过,0 失败,无阻塞性问题。验证 head:`629e603dd`(`HEAD^2`)。

**A/B 结论(核心主张,wire oracle):** 用真实打包 CLI(`node dist/cli.js`)对零依赖本地 OpenAI mock 跑同一场景(请求1 → 含两个未完成项的 `todo_write`;请求2 → 工具结果轮)。head 的工具结果请求中,reminder 以独立 `user` 消息出现在 `role:"tool"` function response 之后(`system→user→assistant(todo_write)→tool→user(reminder)`);将 `todoWrite.ts` 回退到 base 重新构建的 control(仅差生成逻辑这一个 hunk,仍保留 client/ACP 注入代码)在同一请求中 reminder 缺失。两侧 `todo_write` 均真实执行,场景完全一致,reminder 由“有”翻转为“无”。10/10 断言通过。

**次要主张:** 用真实编译后的 `Config`(无 mock)验证 prompt ID 隔离/清理/续接/自动轮次隔离与继承/子 Config 隔离,23/23 通过。ACP 注入顺序(function response → reminder → 中途用户输入)经代码审读 + 460 个 Session 测试确认。

**空泛性检查:** 将 client 的 ToolResult 注入置为 `if (false && …)` 后,核心新测试在目标断言 `expected -1 to be greater than 0` 上失败,证明该测试确实由生产代码钉住,非空泛。

**门禁:** core 相关 4 文件 1118/1118 通过;cli nonInteractive 91 通过/1 跳过;ACP Session 460/460 通过;`npm run typecheck` 全 workspace exit 0。均在合并提交 HEAD(已合入当前 main)上运行。

**未覆盖:** ACP 未自建端到端 wire harness(由 Session 测试 + 代码审读覆盖);浅克隆(depth 2,7 个提交仅 1 个可达)导致无法逐提交归因,仅验证聚合 diff;retry/continue 与 cron/notification 隔离在真实 Config 方法层验证,未跑完整多轮 ACP wire 场景;未对 typecheck 门禁植入违规以证明其“存活”;未对 `escapeSystemReminderTags` 做对抗性 fuzz;`todoStopGuard` 交互按 PR 声明不在范围内。

</details>

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

Qwen Code · sandboxed verification

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Review — fix(core): preserve active Todo context across tool turns

What it does

After a successful todo_write, the unfinished items are kept as a bounded <system-reminder> on Config, keyed by a work-chain owner rather than by raw prompt ID. Retry/continue turns move the owner forward; ordinary new prompts and startNewSession clear it; cron/notification/teammate turns get their own owner unless they carry a todoWorkChainId captured at registration time (new todoWorkChainContext ALS, threaded through BackgroundTaskRegistry, BackgroundShellRegistry, MonitorRegistry, CronScheduler.scheduleWakeup). The reminder is then appended after function responses in the Core loop (client.ts) and the ACP loop (Session.ts), always before drained mid-turn user input.

The ownership model is the good part of this PR — the "reminder can outlive its work chain" problem is real, and keying on an owner id (instead of re-reading the persisted todo file) is the right call. A few things I'd want addressed before merge.


🔴 Todo reminders accumulate permanently in conversation history

client.ts:2927-2929 appends the reminder to requestToSend on every ToolResult turn, and Session.ts:4592-4597 does the same for the daemon. GeminiChat.sendMessageStream pushes the outgoing request verbatim into history (geminiChat.ts:2293this.history.push(userContent)), so every tool turn leaves a permanent copy behind.

The 4,000-char cap bounds a single copy, not the aggregate:

todo list per-turn 40 tool turns, history cumulative prompt reads
6 items, typical (~700 chars) ~180 tok ~7 K tok ~145 K tok
at the 4,000-char cap ~1050 tok ~42 K tok ~860 K tok

Two aggravating factors:

  • Microcompaction won't reclaim it — it clears old tool results and media (geminiChat.ts:1875), not user-role text parts. These reminders survive as dead weight and pull auto-compaction forward, which can evict real context.
  • Growth is quadratic in tool turns, because turn N re-reads all N-1 prior copies.

(To be fair: it does not break prompt caching — the reminder is always appended at the tail, so the cached prefix stays stable.)

Options, roughly in order of preference:

  1. Strip the previous copy from history before appending the new one — one live reminder at a time is exactly the salience you want.
  2. Re-inject only when the todo snapshot changed since the last injection, plus a periodic refresh every N turns.
  3. Shrink the payload: - [in_progress] Run tests lines instead of full JSON.stringify, and drop the cap to ~800 chars.

Worth measuring on a real 30–50 tool-call task before merging, since the whole point of the feature is long multi-step runs — exactly the case where this costs the most.


🟡 The "new ordinary prompt clears stale todos" invariant is untested

I ran a mutation probe against the changed suites. 6 of 7 targeted mutations were killed — good tests. The survivor:

   startActiveTodoWorkChain(promptId: string, continuedFrom?: string): void {
     if (!continuedFrom) {
-      reminders.clear();
       owners.clear();

config.test.ts stays green at 471/471.

That's one of the PR's headline safety properties (an abandoned task's todos must not bleed into an unrelated new prompt). The only Config-level coverage for clearing is via startNewSession; Session.test.ts and client.test.ts both mock Config, so neither can catch it. A three-line test on the real Config would close it:

config.startActiveTodoWorkChain('p1');
config.setActiveTodoReminder('p1', 'old work');
config.startActiveTodoWorkChain('p2');
expect(config.getActiveTodoReminder('p2')).toBeUndefined();

For the record, the killed mutations were: removing the ToolResult injection in client.ts; removing the endAutomaticActiveTodoWorkChain cleanup; reordering the reminder after drained mid-turn input; dropping the scheduler's owner-mapping lookup; dropping the work-chain batch split in nonInteractiveCli; and including completed todos in the reminder.

🟡 The TUI wiring has no tests at all

useGeminiStream.ts gained the todoWorkChainId threading from all four registries plus the notification-batching split, and useGeminiStream.test.tsx is untouched — while the non-interactive and ACP paths both got tests. The batching split is user-visible: notifications from different work chains no longer merge into one turn, so a session with concurrent background work now issues more model turns than before. That deserves at least one test.

🟡 enterWith in Session.runToolCalls leaks into the turn loop

// Session.ts:6454
promptIdContext.enterWith(promptId);
todoWorkChainContext.enterWith(this.config.getActiveTodoWorkChainOwner(promptId));

Unlike run(), enterWith mutates the current async context and persists after runToolCalls resolves — so anything registering a background task/monitor/shell later in the turn loop, outside a tool call, silently inherits the chain. The daemon executes tools directly (Session.ts:8078, not via CoreToolScheduler), so the binding is genuinely needed here, but wrapping the batch body in todoWorkChainContext.run(...) would scope it. If enterWith is deliberate, please mirror the explanatory comment the existing site at Session.ts:2652 carries — this one has none.


Nits

  • Mixed part shapes. client.ts splices/appends a bare string; Session.ts uses { text: … }. Both work through createUserContent, but it forces the dedupe check at client.ts:2890-2897 to test two shapes. Pick one.
  • Cron ordering diverges. client.ts:2898-2911 inserts the reminder at index 0 — before the session system reminders — while Session.ts:5339-5346 puts it after #buildInitialSystemReminders(). Harmless, but the two loops should agree.
  • No JSDoc on the new Config API. Eight new public/private methods (config.ts:6043-6122) with genuinely subtle semantics: clear-all vs. selective clear, the owner === promptId ⇒ delete branch in startAutomaticActiveTodoWorkChain, and the reverse-scan refcount in endAutomaticActiveTodoWorkChain. Neighbouring machinery in the same file (e.g. getFileReadCache's Object.create note) is documented in detail. Same for the new todoWorkChainContext export — compare sessionIdContext.ts, which documents itself by reference to promptIdContext.
  • Test placement. The four new Config tests land inside describe('Model Switching and Config Updates'). They want their own describe block.
  • todoWrite keys off promptIdContext, not the owner the scheduler already resolved (todoWrite.ts:259). Equivalent for mapped prompt IDs; for unmapped ones (a SendMessageType.Goal turn, say) it writes an orphan entry under the raw prompt ID that nothing reads and only a later startActiveTodoWorkChain clears.
  • currentPromptId never resets after a teammate turn (nonInteractiveCli.ts:1688-1690). Once a teammate message arrives, every subsequent turn — including plain ToolResult turns of the original chain — reports under ${prompt_id}/teammate/N. It's correct for todo ownership and necessary so endAutomaticActiveTodoWorkChain fires on the terminal turn, but it does shift telemetry and loop-detector attribution for the rest of the run. Worth a comment.
  • JSON truncation (todoWrite.ts:266-269) cuts mid-array; the [truncated] marker helps, but a line-per-item rendering truncated at item boundaries would read better to the model.

What's good

  • escapeSystemReminderTags before embedding model-authored todo text — correct injection defense, and the truncation happens after escaping so it can't synthesize a closing tag.
  • Child-Config isolation via the hasOwnProperty own-map trick matches the established getFileReadCache pattern, and it's tested.
  • CoreToolScheduler binding promptIdContext around invocation.execute (coreToolScheduler.ts:4224-4231) is a real drive-by fix: a tool resumed from a confirmation callback previously ran with no prompt ID in ALS. Good that it came with a test.
  • Ownership deliberately not inferred from Todo Stop Guard lineage, with a test that locks it.
  • todoStopGuardBackgroundBaseline now always captured — I checked, CronScheduler's constructor is inert (no I/O, no timers), so this costs nothing even with cron enabled and the guard off.

Verification I ran

Worktree at PR head (df35e108), all green:

suite result
todoWrite + monitorRegistry + backgroundShellRegistry + loop-wakeup 165 passed
config + client + client-goal + coreToolScheduler 1113 passed
nonInteractiveCli 91 passed, 1 skipped
background-tasks passed
ACP Session 460 passed

Prettier clean on all changed files. The full ACP Session run finished in ~37 s here with no timeout, so the flake noted in the PR description didn't reproduce.

中文摘要

结论:设计方向对,合并前建议先处理历史膨胀问题。

按 work-chain owner 隔离 Todo reminder(而不是重读持久化的 todo 文件)是正确的做法,cron / notification / background task 在注册时捕获 owner、retry/continue 转移 owner、新的普通 prompt 清空——这套模型是站得住的。

🔴 主要问题:reminder 会永久累积进 history。 client.ts:2927-2929Session.ts:4592-4597每一个 tool-result 轮次都追加 reminder,而 geminiChat.ts:2293 会把请求原样 push 进 history。4000 字符的上限只约束单份,不约束总量:40 次工具调用、按上限算约 42K token 沉淀在 history 里,累计读取约 860K token;typical 情况(6 条 todo)约 7K / 145K token。而且 microcompaction 只清理旧的 tool result 和媒体,不会回收这些 user-role 文本,反而会提前触发自动压缩、挤掉真正有用的上下文。增长是轮次的平方级。(好消息:reminder 一律追加在尾部,不会破坏 prompt cache。)建议:注入新的之前先从 history 里剥掉上一份;或只在 todo 快照变化时重注入 + 每 N 轮刷新一次;或把 payload 换成 - [status] content 行、上限降到 ~800 字符。建议在真实的 30–50 次工具调用任务上量一下再合。

🟡 变异测试:7 个变异杀掉 6 个,测试质量总体不错。 唯一存活的是删掉 startActiveTodoWorkChain 非续接分支里的 reminders.clear() —— config.test.ts 依然 471/471 全绿。这正是本 PR 的核心安全性质之一(放弃的任务不能把 todo 泄漏到新 prompt);Session.test.ts / client.test.ts 都 mock 了 Config,抓不到。补一个三行的真实 Config 测试即可。

🟡 TUI 侧零测试。 useGeminiStream.ts 改了四个 registry 的 todoWorkChainId 透传和通知批处理切分,useGeminiStream.test.tsx 未动。批处理切分是用户可见行为变化:不同 work chain 的通知不再合并成一轮,会多产生模型轮次。

🟡 Session.ts:6454enterWith 会泄漏到 turn loop。 daemon 直接执行工具(Session.ts:8078,不走 CoreToolScheduler),所以这里确实需要绑定,但 enterWith 会在 runToolCalls 返回后继续生效;建议改成 run() 包住批次,或至少补上像 Session.ts:2652 那样的说明注释。

其余为 nit:两条路径 part 形状不一致(裸 string vs {text})、cron 分支注入位置两边不一致、新增 8 个 Config 方法零 JSDoc(语义并不显然)、新测试挂在 describe('Model Switching and Config Updates') 下、todoWritepromptIdContext 而非 scheduler 已解析好的 owner、teammate 轮次后 currentPromptId 不复位、JSON 截断在数组中间。

做得好的地方: 嵌入模型产出的 todo 文本前先 escapeSystemReminderTags(且截断在转义之后,无法伪造闭合标签);child Config 用 hasOwnProperty 隔离,与 getFileReadCache 既有模式一致且有测试;CoreToolSchedulerinvocation.execute 绑定 promptIdContext 顺手修了确认回调恢复执行时 ALS 无 prompt id 的老洞;明确不从 Todo Stop Guard lineage 推断 ownership 并有测试锁住。todoStopGuardBackgroundBaseline 改成无条件捕获也没有代价——CronScheduler 构造函数是空的,无 I/O 无 timer。

已验证: 在 PR head 的 worktree 上跑了全部受影响用例(165 / 1113 / 91 / 460),全绿;prettier 全过;PR 描述里提到的 ACP Session 超时用例在本地约 37 秒跑完,未复现。

…y growth

Every injected reminder copy lands permanently in chat history, so per-turn
injection grew the live context linearly with tool turns. Tool-turn injection
now re-issues the reminder only every third tool turn since the state was
last presented; turn-start injections always fire and reset the cadence. The
payload becomes a compact status/content line list capped at 800 characters.
History stays append-only, so provider prefix caching is unaffected.

Also: cover the new-ordinary-prompt-clears-stale-reminders invariant on the
real Config, add TUI coverage for the work-chain notification batch split,
cover todoWorkChainId continuation forwarding, and document the deliberate
enterWith binding in the daemon tool runner.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@wenshao All four points addressed in 7c3aa85.

🔴 History accumulation

I went with your options 2+3 combined rather than option 1 (stripping the previous copy), for two reasons:

  1. As you noted, the current injection is append-only and keeps the cached prefix stable. Option 1 rewrites the previous user message on every tool turn, which creates a cache invalidation point at the tail each turn, and history mutation touches machinery that assumes the transcript matches what the model saw — chat recording, session resume, and the retry-strip path.
  2. "Re-inject only when the snapshot changed" can't carry the load on its own: the reminder only changes on todo_write, and the todo_write tool result already presents the full state at exactly that moment. The periodic refresh is the part that does the work, so that's what I built.

Implementation: Config.takeActiveTodoReminder(promptId, force?). Tool-turn injection re-issues the reminder only every third tool turn since the state was last presented — setActiveTodoReminder resets the cadence because the todo_write result itself counts as a presentation. Turn-start injections (retry, cron, notification) pass force and always fire, resetting the cadence. The payload is now a compact - [status] content line list with the cap lowered from 4,000 to 800 chars.

Against your table: a 40-tool-turn task with a typical 6-item list drops from ~40 copies × ~180 tok (~7K tok in history) to ~13 copies × ~60 tok (~0.8K tok), and cumulative prompt reads go from quadratic to near-linear. The microcompaction-can't-reclaim-it concern shrinks to noise at that size. History stays append-only, so prefix caching is untouched.

🟡 The untested clear invariant

Added exactly your three-line test on the real Config (plus a cadence test for the new throttle). Removing reminders.clear() from startActiveTodoWorkChain now fails the suite.

🟡 TUI coverage

Added a useGeminiStream regression: notifications from different work chains drain as separate turns, same-chain items still batch into one turn, and todoWorkChainId is forwarded into the send options.

🟡 enterWith in runToolCalls

It is deliberate — the daemon executes tools directly rather than through CoreToolScheduler, and background task/shell/monitor registration can happen in async continuations of the batch after runToolCalls resolves. Added the explanatory comment in the style of the existing binding site.

Also closed one gap from the earlier review rounds: the todoWorkChainIdstartAutomaticActiveTodoWorkChain(continuedFrom) forwarding had no test anywhere (replacing the argument with undefined kept everything green); there's now a direct client test. The design doc documents the cadence tradeoff.

Verification: core 833 / ACP Session 460 / nonInteractiveCli 91 all passing, plus workspace typecheck and changed-file ESLint/Prettier.

中文

四点已在 7c3aa85 全部处理。history 累积采用方案 2+3 组合(每 3 个工具轮刷新一次 + 紧凑行格式、cap 降至 800)而非方案 1:append-only 保住 prefix cache,且不引入 history 变异对 recording/resume/retry-strip 的连带风险;"变化时注入"承担不了主要工作(todo_write 结果本身已呈现状态),周期刷新才是承重部分。40 轮任务 history 副本从 ~7K tok 降至 ~0.8K tok。clear 不变量按你给的三行补在真实 Config 上;TUI 补了 work chain 拆分/合批/透传回归;enterWith 是刻意的,已补注释。另补了 todoWorkChainId 转发测试,设计文档已记录取舍。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining ordering gap from the runtime verification: active Todo reminders on ToolResult turns are now inserted after leading functionResponse parts and before drained mid-turn user input. Verified with a focused core client test. I did not expand this into broader history or API cleanup beyond the existing PR scope.

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Review: preserve active Todo context across tool turns

Verdict: LGTM with non-blocking follow-ups. The mechanism is sound, the ownership model holds up under the edge cases I traced, and I verified the feature works end-to-end against a real CLI build. Nothing here blocks merge.

What I ran (worktree at 62310b02, merges cleanly onto main)

Check Result
npm run typecheck (all workspaces) pass, 0 errors
prettier --check + eslint on all 29 changed files clean
packages/core: client, coreToolScheduler, config, todoWrite, background-tasks, monitorRegistry, backgroundShellRegistry, loop-wakeup, client-goal 936 passed
packages/cli: Session.test, Session.review-lease, Session.worktree, nonInteractiveCli, useGeminiStream 731 passed

The full Session.test.ts run passed all 460 tests here (30.5 s), including the three mid-turn drain cases — I did not reproduce the 30 s timeout mentioned in the PR body.

Non-vacuity spot check: flipping ACTIVE_TODO_REMINDER_REFRESH_TURNS from 3 to 1 fails re-issues the active Todo reminder only every third tool turn, so the cadence test is real.

End-to-end verification

Built dist/cli.js and ran headless (-p ... --approval-mode yolo) against a fake OpenAI SSE server with an isolated HOME, scripting the model to call todo_write (2 unfinished) then three more tool calls, logging every request body.

req 1  msgs=2   reminderMsgs=0
req 2  msgs=4   reminderMsgs=0     <- tool result for todo_write (counts as the presentation)
req 3  msgs=6   reminderMsgs=0
req 4  msgs=9   reminderMsgs=1     <- injected on the 3rd tool turn
req 5  msgs=11  reminderMsgs=1

Placement in req 4 is exactly as designed — the reminder lands as its own user message after the tool message, so the functionCall/functionResponse pairing stays intact:

6 assistant [tool_calls]
7 tool       Command: echo step3 ... Exit Code: 0
8 user       <system-reminder> The current task still has unfinished todo items:
             - [in_progress] ALPHA-UNFINISHED-TASK
             - [pending] BETA-PENDING-TASK ...

A second run where the model marks every todo completed at req 5 confirms the clear path: no further reminder is injected across reqs 5–7.

I also checked the design doc's prefix-caching claim directly — every request's message list is a strict prefix extension of the previous one (sharedPrefix === prev.length for all 6 transitions), so injection does not invalidate provider prefix caches.

Findings

1. (Important) History growth is linear and uncapped, and old copies go stale.
Every injected reminder is recorded permanently. The 1-in-3 cadence reduces the constant but the growth is still linear in tool turns, with no ceiling on total re-issues per work chain. Payload measured at 273 chars for two short todos; the cap allows ~1030. A 150-tool-turn autonomous run lands ~50 copies ≈ 15–50 KB ≈ 4–13k tokens.

Worse, the copies are not superseded: my second E2E shows reqs 5–7 still carrying a reminder that asserts items are unfinished after they were all completed. The todo_write result is the only counter-signal, and it sits further back in history each turn.

Since history must stay append-only for cache reasons, the cheap fixes are a hard cap on re-issues per work chain, or backing the cadence off (3, 6, 12, …) rather than holding it flat.

2. (Important) Notification batching now splits on todoWorkChainId — an extra model turn per work chain.
useGeminiStream.ts and nonInteractiveCli.ts both add todoWorkChainId equality to the batch-boundary loop. Two monitors launched under two different user prompts, firing while idle, previously drained as one turn and now drain as two. That is a correct consequence of the ownership model — batching them would force one owner on the whole turn — but it is a cost/latency change that lands on every user with more than one background task, and the PR's Risk section doesn't mention it. Worth a line there.

3. (Minor) GeminiClient.activeTodoWorkChainPromptId is never reset on session change.
Config.startNewSession clears the three maps, and activeAutomaticTodoWorkChainPromptIds is cleared on the next UserQuery, but activeTodoWorkChainPromptId (client.ts:330) survives. A Retry issued right after /clear calls startActiveTodoWorkChain(newId, <dead prompt id from the previous session>), so the chain's owner key is an id that no longer exists. Harmless today — prompt ids embed the session id so there's no collision, and the reminder map is empty anyway — but it's a landmine. Same for Session.activeTodoWorkChainPromptId in the daemon.

4. (Minor) PR body says the payload is capped at 4,000 characters; the code caps at 800.
MAX_ACTIVE_TODO_CONTEXT_CHARS = 800 (todoWrite.ts:25), which matches the design doc. Both the English and Chinese sections of the description say 4,000.

5. (Minor) Truncation runs after XML escaping.
escapeSystemReminderTags(...) then .slice(0, 800) can cut inside an entity (&lt;&l). No injection risk — escaping already neutralised the tag, and a partial entity can't re-open one — but slicing on a line boundary before escaping would be cleaner and would avoid dropping a todo mid-word.

6. (Minor) this.config.getActiveTodoWorkChainOwner?.(...) in coreToolScheduler.ts:4226.
Config declares the method non-optional, so the ?. plus the two-level ?? fallback is dead code for every real Config and exists only for partial test mocks. Either drop the ?. or narrow the scheduler's config type.

7. (Minor) Two Todo Stop Guard changes that the PR body says aren't happening.
The description states "The existing experimental Todo Stop Guard remains unchanged," but the constructor now captures todoStopGuardBackgroundBaseline unconditionally instead of only when the guard is enabled (Session.ts:1354), and ordinary prompts now call #resetTodoStopGuardBackgroundLineage() when the guard is disabled (Session.ts:2862). I believe both are right — otherwise relatedAgentIds never resets in the default configuration — but they deserve a sentence, since a reviewer scanning for "guard untouched" will trip on them.

Relatedly, the const continuesCurrentWorkChain = ... extractions in the cron and notification drains are pure churn: the value still has exactly one consumer, #prepareTodoStopGuardForAutomaticTurn.

8. (Nit / hardening) todoWorkChainContext.enterWith in Session.#runToolCalls.
The comment justifies enterWith over run well (post-resolve background registration), and the daemon rebinds on every tool batch, so the exposure window is narrow. But #executePrompt binds promptIdContext at turn start and not todoWorkChainContext, so between a turn's start and its first runToolCalls the store still holds the previous turn's owner. Any registration path that doesn't go through runToolCalls would capture it. Consider binding both at the same place.

Test coverage

Coverage of the state machine is genuinely good — the Config unit tests hit continuation, isolation, prototype-child isolation, session reset, and the terminal-release path, and client.test.ts covers ownership surviving an automatic turn's tool-result turns.

One gap, and it's the exact regression the ownership machinery exists to prevent: nothing tests that a todo_write executed inside an unrelated cron/notification turn leaves the foreground user chain's reminder alone. The Config tests drive setActiveTodoReminder directly, and the Session tests mock takeActiveTodoReminder wholesale, so the real chain — promptIdContextTodoWriteToolsetActiveTodoReminder → owner resolution — is never exercised for an automatic turn. Worth one integration-level test.

Notes on things I checked and found fine

  • promptIdContext.run in CoreToolScheduler closes a real gap: the TUI schedules tools outside submitQuery's ALS frame, so promptIdContext.getStore() in TodoWriteTool would otherwise have been undefined there. The approval-resume test confirms it also survives confirmation from an unrelated context.
  • The /automatic/N and /teammate/N prompt-id suffixes in nonInteractiveCli are safe for the OpenAI logger — sanitizeDiagnosticSuffix strips /, and sessionIdFromPromptId splits on ######## before the suffix, so session grouping is preserved.
  • endAutomaticActiveTodoWorkChain's owner scan correctly preserves the foreground reminder, because the ordinary chain always installs a self-mapping (owners.set(promptId, promptId)) that keeps the owner present in owners.values().
  • Subagent configs are Object.create(parent), and the hasOwnProperty lazy-own-map pattern (already used for approvalMode, memoryPressureMonitor, fileReadCache) correctly isolates them.
  • todoWorkChainId is not written to the background-shell status sidecar, so no stale cross-session ids come back from disk on that path.
  • requestToSend is always a fresh array from flatMapTextParts, so the in-place splice can't mutate a caller-owned list.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@ruoyu0214 please review this pr !

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

整体设计合理,work-chain ownership 隔离和 every-3rd-turn cadence 是正确的 tradeoff。几个观察:

设计层面

  1. Config 上用 3 个 Map + getOwn*() hasOwnProperty 守卫来处理 Object.create(parent) 子 Config 继承——能 work,但这个模式比较脆弱。如果后续有人直接赋值 this.activeTodoReminders = ... 而不是走 getter,就会静默破坏隔离。考虑过用一个单独的 ActiveTodoState 类封装这三个 Map 吗?这样 own-property 检查只需一处。

  2. takeActiveTodoReminder 的 cadence 逻辑:setActiveTodoReminder 时 reset counter 到 0,然后每 3 次 tool turn 才 re-inject。如果模型在第 1 次 tool turn 就尝试 stop(此时 reminder 未注入),这个 case 完全依赖 todoStopGuard(默认关闭)。设计文档里说了不改 stop 语义,所以这是 by design,但值得在 PR description 里显式提一下这个 gap。

实现细节

  1. Session.tspromptIdContext.enterWith(promptId) + todoWorkChainContext.enterWith(...) 的注释解释了为什么用 enterWith 而非 run——合理,但这意味着 daemon 路径的 ALS 生命周期完全依赖下一次 rebind。如果 runToolCalls 之后有 async continuation 在下一个 turn 之前跑(比如 background task registration 的 delayed callback),它拿到的是当前 turn 的 context。这应该是期望行为,确认一下。

  2. client.ts finally 块里 endAutomaticActiveTodoWorkChain 的条件 (!normalCompletion || !hasToolCalls) —— 如果 turn 正常完成但没有 tool calls,就清理 ownership。这意味着一个纯文本回复的 notification turn 不会把 reminder 带到后续。逻辑正确,但 hasToolCalls 的赋值分散在 3 个 return 点(steeredTurn / hookTurn / continueTurn),后续重构容易漏。

  3. todoWrite.ts 里 reminder 模板是硬编码的英文字符串。如果后续要做 i18n 或者 A/B test 不同 prompt 措辞,这里需要抽出来。当前阶段可以不动。

测试

覆盖充分,Core/ACP/nonInteractive/useGeminiStream 四层都有。config.test.ts 的 cadence 和 isolation 测试写得清楚。

总体 LGTM,上面 1 和 4 是可以后续跟进的 maintainability 建议,不 block。

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

已 review 并留言。总结:

PR #7919 — 29 files, +1260/-53,在 todo_write 后按 work-chain prompt ID 隔离地保留未完成 Todo reminder,每 3 个 tool turn re-inject 一次(避免 context 线性增长),Core 和 ACP 双路径都处理了。

给了 comment(非 approve/request-changes),主要建议:

  1. 3 个 Map + hasOwnProperty 守卫模式可以封装成单独类,降低后续误用风险
  2. hasToolCalls 赋值分散在 3 个 return 点,重构时容易漏
  3. 确认 enterWith 的 ALS 生命周期在 delayed callback 场景下是期望行为

不 block,整体 LGTM。

@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 — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/core/src/core/client.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Closeout summary: no code was changed in this round. The remaining comments requested additional coverage for teammate inheritance and cron carry-back, but did not identify a current behavior failure. After the existing review rounds, repository policy limits further growth to Critical fixes, so these tests are deferred as follow-up. Current CI is passing.

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Re-verification at the current head e87fa929

Follow-up to my runtime verification at 2d9f7f58 and my last review at 62310b02. Both were pinned to older heads; this round re-builds the PR at e87fa9295 and spends the effort on the three things the earlier rounds left open: whether the merge from main preserved the code I reviewed, whether the ordering fix actually holds in the interactive loop (the runtime report's only real finding), and whether the ownership machinery survives a real concurrent automatic turn — the coverage the author deferred as follow-up.

Verdict: merge-ready. One new minor finding (an unbounded map), one stale line in the PR description. Nothing blocking.

1. The merge from main changed nothing in the PR

e87fa929 is a merge of origin/main (26600896) into 62310b02, the head I reviewed last. Diff-of-diffs across all 14 production files — PR diff vs its old base compared to PR diff vs the new base:

file changed lines vs. reviewed head
core/client.ts 88 identical
config/config.ts 125 identical
acp-integration/session/Session.ts 116 identical
core/coreToolScheduler.ts 43 identical
tools/todoWrite.ts 24 identical
nonInteractiveCli.ts · useGeminiStream.ts 19 · 18 identical
7 registry/scheduler/context files 35 total identical

main's only overlap with this PR is config.ts, where it added an unrelated allowPrivateNetworkHooks accessor (+17 lines); the merge kept it and did not disturb the reminder maps. So what is below is the code I already reviewed at 62310b02 — no re-review of the logic needed, only the behaviour that was still unverified.

2. The runtime report's finding — "user input last" in the interactive loop — is fixed

That report found the ACP loop honouring the invariant while the interactive CLI did not: typing during a tool call produced function response → user's message → reminder, i.e. the reminder read last. 62310b02 moved the core-loop insertion to a splice before the first non-functionResponse part. I drove the real TUI in a pty (built dist/cli.js v0.21.1, mock provider recording every request), typed a message 4 s into an 18-second shell call, and captured the request it produced:

Order on the wire is tool → todo reminder → MIDTURN-USER-INPUT: …. The user's newest direction is last. Reverting the splice to the old append (requestToSend = [...requestToSend, activeTodoReminder]) fails one client.test.ts case, so the new test pins it.

3. The deferred coverage gap: an unrelated automatic turn cannot touch the foreground chain

This is the regression the whole ownership design exists to prevent, and — as noted in my last review — nothing tested it end to end: the Config tests drive setActiveTodoReminder directly and the Session tests mock takeActiveTodoReminder, so the real chain promptIdContext → TodoWriteTool → setActiveTodoReminder → owner resolution → injection was never exercised for an automatic turn.

One headless session, all real: the foreground task writes todos ALPHA, registers a cron job via cron_create (which captures no chain) and a wakeup via loop_wakeup (which captures the chain at registration), then ends. The cron turn fires first and writes its own todos BRAVO.

turn opens with reads writes
foreground user prompt ALPHA (injected on its 3rd tool turn)
cron (cron_create) bare cron prompt, no ALPHA its own chain only BRAVO (own chain, injected on its own 3rd tool turn)
loop wakeup (loop_wakeup) ALPHA, byte-identical (305 chars) to the foreground copy inherited chain
next cron firing bare cron prompt, no ALPHA, no BRAVO

The wakeup turn is the oracle: it reads the foreground reminder after the cron turn wrote a different list, and gets ALPHA unchanged. BRAVO is released when its turn ends and never reappears. A 31-assertion probe against the compiled Config covers the rest of the lattice (retry transfer, teammate inheritance, two concurrent automatic turns on one chain, a chain id captured before a new user prompt, subagent Object.create isolation both ways, completion clearing) — 30 pass, 1 fails; that one is finding A below.

Teammate inheritance and cron carry-back, the two items deferred in the closeout, both behave correctly at the Config level; only their unit tests are missing.

4. Cadence and cost after the throttle

Measured, not inferred: a fresh reminder lands on model requests 4, 7, 10 of an 11-request run — every third tool-result turn, counting the todo_write's own result turn as the first. 305 bytes per copy for three short todos, ≤ 1.9 % of the payload across the run. Amortised per-turn cost is ~343 B against the pre-throttle ~4,218 B, so the throttle plus the 800-char cap is worth roughly 12×. A second run that marks every todo completed mid-way injects nothing further — the clear path works.

Two consequences worth stating rather than fixing:

  • Short tails get no reminder at all. Requests 2 and 3 — the first two tool-result turns after the write — carry none, so a chain that ends within two tool turns of its last todo_write is never reminded. The todo_write result itself still carries the full list at that point, which is the design's rationale, but this is the same gap @ruoyu0214 raised and it belongs in the description.
  • Old copies are never superseded. After the all-completed write, history still holds a copy asserting there are unfinished items. Harmless while it is one copy; it is the reason growth is linear rather than bounded.

Findings

A. (New, minor) The cadence-counter map is never released. endAutomaticActiveTodoWorkChain deletes the owner mapping and the reminder, but not the activeTodoReminderTurns entry (config.ts:6162-6169). Probe: after 500 isolated automatic chains that each wrote todos and were released, activeTodoReminders and activeTodoWorkChainOwners hold 1 entry each — activeTodoReminderTurns holds 501. It only accumulates for isolated automatic turns that call todo_write, and the next ordinary user prompt (or startNewSession) clears it, so it is bounded in interactive use; a long-lived daemon session driven only by cron/notification turns has no such boundary. One line in the release branch:

if (![...owners.values()].includes(owner)) {
  this.getOwnActiveTodoReminders().delete(owner);
  this.getOwnActiveTodoReminderTurns().delete(owner);   // <-- add
}

B. (Carried, description) The PR body still says 4,000 characters. MAX_ACTIVE_TODO_CONTEXT_CHARS = 800 (todoWrite.ts:25), and docs/design/active-todo-context.md says 800 — only the PR description (both the English and the 中文 Risk sections) still says 4,000. The description also never mentions the 1-in-3 cadence or the notification-batching split, both of which are the actual tradeoffs a reader of Risk & Scope wants — and Reviewer Test Plan step 1 is now wrong because of it: "performs at least one additional tool call. Confirm the next model request contains the unfinished Todo reminder" does not hold under the throttle, it takes three. The design doc is accurate and complete on all of this; only the body drifted.

C. (Carried, inert) activeTodoWorkChainPromptId is still not reset on session change in either loop (client.ts:330, Session.ts:1244). Probe case 8 pins why it does not matter today: a chain id captured before the boundary reads nothing, and releasing it leaves the live chain intact. Still a landmine for whoever changes the clear semantics.

Also unchanged, all cosmetic: truncation runs after XML escaping so a slice can cut inside an entity; the getActiveTodoWorkChainOwner?.() optional call in coreToolScheduler.ts:4226 is dead for every real Config; the two Todo Stop Guard changes the body says are not happening (Session.ts:1354, 2863) are correct but undocumented.

Suites and mutation teeth at e87fa929

suite result
core: todoWrite, config, coreToolScheduler, background-tasks, monitorRegistry, backgroundShellRegistry, loop-wakeup 1076 passed
core: client · client-goal 306 · 22 passed
cli: nonInteractiveCli, Session, Session.review-lease, Session.worktree 563 passed, 1 skipped
cli: useGeminiStream 168 passed
prettier --check + eslint, all 29 changed files clean
compiled-Config ownership probe 30 / 31 (finding A)

Correction to my first runtime report: I noted client.test.ts failing to collect locally (No "logStartSession" export) and said CI was the only authority for its new client-loop assertions. That was my environment — vitest 3.2.7 in a borrowed node_modules against the lockfile's 3.2.4. Under the pinned version the file passes 306/306, so those assertions do execute.

Mutation probes, each asserted to have actually landed before running — 7 killed, 0 survivors:

mutation killed by
ToolResult reminder appended at the end instead of spliced before non-functionResponse parts client.test.ts (1 failure)
ACP reminder moved after the drained mid-turn input Session.test.ts (1)
reminders.clear() dropped from a fresh work chain config.test.ts (1) — the only survivor of my earlier mutation pass
cadence constant 3 → 1 config.test.ts (1)
automatic turn ignores the captured todoWorkChainId client.test.ts (1)
todoWrite ignores promptIdContext todoWrite.test.ts (4)
TUI notification batching ignores work-chain boundaries useGeminiStream.test.tsx (1)

Setup and limits

Built from source in an isolated worktree at e87fa9295 (macOS, Node 22.23.1): tsc for channels / web-templates / core / acp-bridge, then esbuild + copy_bundle_assets; takeActiveTodoReminder confirmed present in dist/chunks/. Three drivers: headless -p … --approval-mode yolo, the interactive TUI over node-pty + @xterm/headless, and a stateless mock OpenAI provider that logs every /v1/chat/completions body. Two local-environment adaptations, neither affecting the PR's code: @octokit/rest marked external (my checkout predates packages/channels/github's dependency) and @testing-library restored from a sibling checkout.

Not covered: retry/continue through a real ACP client (probe-level only), teammate turns end to end, Windows/macOS-vs-Linux differences, and behaviour with a real model rather than a scripted one.

中文版本

在当前 head e87fa929 上重新验证

接续我在 2d9f7f58 上的 运行时验证 与在 62310b02 上的 上一轮评审。两者都固定在更早的提交上,因此本轮在 e87fa9295 重新构建,并把精力放在前两轮遗留的三件事上:合并 main 后我评审过的代码是否被改动、顺序修复在交互式循环里是否真正成立(运行时那轮唯一的实质问题),以及 ownership 机制在真实并发自动轮次下是否成立(作者作为 follow-up 推迟的那部分覆盖)。

结论:可以合并。 新增 1 个次要问题(一个无上界的 Map),PR 描述有 1 处过期表述。都不阻塞。

1. 合并 main 没有改动 PR 本身

e87fa929 是把 origin/main26600896)合并进我上轮评审的 62310b02。对全部 14 个生产文件做 diff-of-diffs(PR 相对旧 base 的改动 vs 相对新 base 的改动):client.ts 88 行、config.ts 125 行、Session.ts 116 行、coreToolScheduler.ts 43 行、todoWrite.ts 24 行、nonInteractiveCli.ts / useGeminiStream.ts 19 / 18 行、其余 7 个 registry/scheduler/context 文件共 35 行 —— 全部逐行相同main 与本 PR 唯一重叠是 config.ts,它新增了无关的 allowPrivateNetworkHooks 访问器(+17 行),合并保留了它且没有触动 reminder 的三个 Map。所以下面的代码就是我在 62310b02 已经评审过的那份,逻辑无需重看,只需要补齐当时还没验证的行为。

2. 运行时那轮的问题("用户输入最后")在交互式循环里已修复

那轮发现 ACP 满足该不变式、交互式 CLI 不满足:工具执行期间输入会得到 function response → 用户消息 → reminder,reminder 反而最后被读到。62310b02 把 core 循环的插入改成 splice 到第一个非 functionResponse part 之前。我用 pty 驱动真实 TUI(源码构建的 dist/cli.js v0.21.1,mock provider 记录每个请求),在一个 18 秒 shell 调用开始 4 秒后输入一条消息,抓到的请求顺序是 tool → todo reminder → MIDTURN-USER-INPUT: …,用户最新指令在最后。把 splice 改回旧的 append 会让 client.test.ts 的一个用例失败,说明新测试锁住了这个顺序。

3. 被推迟的那块覆盖:无关自动轮次动不了前台工作链

这正是整套 ownership 设计要防的回归,而我上一轮评审已指出没有端到端测试:Config 测试直接调 setActiveTodoReminderSession 测试整体 mock 了 takeActiveTodoReminder,因此 promptIdContext → TodoWriteTool → setActiveTodoReminder → owner 解析 → 注入 这条真实链路在自动轮次下从未被执行过。

一次全真实的 headless 会话:前台任务写入 todos ALPHA,用 cron_create 注册 cron(不捕获工作链)、用 loop_wakeup 注册唤醒(注册时捕获工作链),然后结束。cron 先触发,并写入自己的 todos BRAVO

轮次 开头是什么 读到 写入
前台 用户 prompt ALPHA(在其第 3 个工具轮注入)
cron(cron_create 裸 cron prompt,没有 ALPHA 只有自己的链 BRAVO(自己的链,在其第 3 个工具轮注入)
loop wakeup(loop_wakeup ALPHA,与前台那份逐字节相同(305 字符) 继承的链
下一次 cron 触发 裸 cron prompt,既无 ALPHA 也无 BRAVO

wakeup 轮次就是判定器:它在 cron 轮次写过另一份列表之后读取前台 reminder,拿到的仍是原样的 ALPHA。BRAVO 在其轮次结束时释放,之后不再出现。另有一个 31 项断言的探针直接驱动编译后的 Config,覆盖 retry 转移、teammate 继承、同一条链上的两个并发自动轮次、在新用户 prompt 之前捕获的链 id、子 agent 的 Object.create 双向隔离、完成后清理等 —— 30 项通过,1 项失败,即下面的问题 A。

closeout 中推迟的 teammate 继承与 cron carry-back,在 Config 层面行为都正确,只是缺单测。

4. 节流后的注入节奏与成本

实测而非推算:11 个请求的运行中,新 reminder 出现在请求 4、7、10 —— 每 3 个 tool-result 轮次一次(把 todo_write 自己的结果轮算作第 1 轮)。3 条短 todo 时每份 305 字节,全程占负载 ≤ 1.9%。摊到每轮约 343 B,对比节流前的约 4,218 B,节流加 800 字符上限大约值 12×。另一次在中途把所有 todo 标记 completed 的运行不再注入任何内容,清理路径有效。

两个值得写清楚、但不必改的后果:

  • 短尾任务完全拿不到 reminder。 请求 2、3(写入后的前两个 tool-result 轮次)都没有,因此在最后一次 todo_write 之后两个工具轮内就结束的链,一次都不会被提醒。此时 todo_write 结果本身仍带着完整列表,这也是设计的理由;但这正是 @ruoyu0214 提的那点,应写进描述。
  • 旧副本永远不会被更正。 全部标记 completed 之后,history 里仍留有一份声称"还有未完成项"的副本。只有一份时无害,但这也是增长为线性而非有界的原因。

问题清单

A.(新增,次要)cadence 计数 Map 不会被释放。 endAutomaticActiveTodoWorkChain 删除了 owner 映射和 reminder,但没有删 activeTodoReminderTurnsconfig.ts:6162-6169)。探针:500 个各自写过 todo 并已释放的隔离自动链之后,activeTodoRemindersactiveTodoWorkChainOwners 各剩 1 项,而 activeTodoReminderTurns501 项。它只在"调用了 todo_write 的隔离自动轮次"上累积,且下一次普通用户 prompt(或 startNewSession)会清空,所以交互式使用是有界的;但只跑 cron / notification 的长生命周期 daemon 会话没有这个边界。释放分支里加一行即可:

if (![...owners.values()].includes(owner)) {
  this.getOwnActiveTodoReminders().delete(owner);
  this.getOwnActiveTodoReminderTurns().delete(owner);   // <-- 补这一行
}

B.(延续,描述)PR 描述仍写着 4,000 字符。 代码是 MAX_ACTIVE_TODO_CONTEXT_CHARS = 800todoWrite.ts:25),docs/design/active-todo-context.md 也写 800,只有 PR 描述(英文和中文的风险段)还是 4,000。描述同样没有提 1/3 注入节奏,也没提 notification 批处理拆分 —— 而这两点恰好是读"风险与范围"的人想看到的取舍;并且 Reviewer 测试计划第 1 步因此已经不成立:"至少继续执行一次其他工具调用。确认下一次模型请求包含未完成 Todo reminder" 在节流下不成立,需要三次。设计文档这些都准确,只有描述没跟上。

C.(延续,当前无害)activeTodoWorkChainPromptId 在 session 切换时仍未复位client.ts:330Session.ts:1244)。探针第 8 项说明了为什么今天无害:在边界之前捕获的链 id 读不到任何东西,释放它也不会影响当前活跃链。但对后续改动清理语义的人来说仍是个坑。

其余问题保持不变,均为表面问题:截断发生在 XML 转义之后,可能切在实体中间;coreToolScheduler.ts:4226getActiveTodoWorkChainOwner?.() 对任何真实 Config 都是死代码;描述声称"不动 Todo Stop Guard",但 Session.ts:13542863 两处改动是对的、只是没写进描述。

e87fa929 上的测试与变异验证

套件 结果
coretodoWriteconfigcoreToolSchedulerbackground-tasksmonitorRegistrybackgroundShellRegistryloop-wakeup 1076 通过
coreclient · client-goal 306 · 22 通过
clinonInteractiveCliSessionSession.review-leaseSession.worktree 563 通过,1 skipped
cliuseGeminiStream 168 通过
全部 29 个变更文件的 prettier --check + eslint 干净
编译后 Config ownership 探针 30 / 31(问题 A)

对首轮运行时报告的更正: 我当时提到 client.test.ts 在本地无法收集(No "logStartSession" export),并说那些 client 循环断言只能以 CI 为准。那是我的环境问题 —— 借用的 node_modules 里是 vitest 3.2.7,而 lockfile 锁定 3.2.4。用锁定版本运行,该文件 306/306 全部通过,那些断言确实执行了。

变异测试(每个变异都先断言"确实落地"再运行)—— 7 杀 0 存活:ToolResult 的 reminder 改为追加到末尾(client.test.ts 1 失败);ACP 的 reminder 移到 drain 输入之后(Session.test.ts 1);删掉新工作链里的 reminders.clear()config.test.ts 1,这是我此前变异测试中唯一的存活者);节奏常量 3 → 1(config.test.ts 1);自动轮次忽略捕获的 todoWorkChainIdclient.test.ts 1);todoWrite 忽略 promptIdContexttodoWrite.test.ts 4);TUI notification 批处理忽略工作链边界(useGeminiStream.test.tsx 1)。

环境与边界

e87fa9295 的隔离 worktree 中从源码构建(macOS,Node 22.23.1):依次 tsc 构建 channels / web-templates / core / acp-bridge,再 esbuild + copy_bundle_assets;确认 dist/chunks/ 中存在 takeActiveTodoReminder。三条驱动路径:无头 -p … --approval-mode yolo、基于 node-pty + @xterm/headless 的交互式 TUI,以及一个记录每个 /v1/chat/completions 请求体的无状态 mock OpenAI provider。两处本地环境适配,都不涉及 PR 代码:把 @octokit/rest 标为 external(我的检出早于 packages/channels/github 引入该依赖),以及从相邻检出恢复 @testing-library

未覆盖:通过真实 ACP 客户端的 retry/continue(仅探针层面)、teammate 轮次端到端、Windows 与 macOS/Linux 差异,以及真实模型(而非脚本化模型)下的行为。

wenshao
wenshao previously approved these changes Jul 29, 2026
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge resolution: PR #7919 ← origin/main

Root cause

Main merged the deferred-tool preload / ToolSearch-threshold feature
(tools.toolSearch.threshold, c55e63c42 lineage), adding a module-level
constant DEFAULT_TOOL_SEARCH_THRESHOLD in packages/core/src/config/config.ts.
This PR adds a different constant, ACTIVE_TODO_REMINDER_REFRESH_TURNS, at the
exact same spot — right after MEMORY_CONTEXT_WARNING_RATIO, before the
ModelsConfig import block. Two unrelated additions on neighbouring lines
produced the conflict.

Textual, not semantic

The two sides only touched adjacent lines; they modify no shared logic. The PR
(active-Todo context preservation) and main (ToolSearch preload budget) are
independent features. Resolution keeps both constants:

const MEMORY_CONTEXT_WARNING_RATIO = 0.15;

/** Re-inject the active Todo reminder every Nth tool turn, not every turn. */
const ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3;

// Default `tools.toolSearch.threshold` (percent of the context window):
// mirrors the settings-schema default in packages/cli.
const DEFAULT_TOOL_SEARCH_THRESHOLD = 10;

config.ts was the only conflicted file. Git auto-merged everything else,
including client.ts / client.test.ts, which both sides also touch — but on
disjoint regions (main: preloadDeferredToolsWithinBudget; PR: active-Todo
work-chain plumbing). I verified the merged config.ts still contains both
features' members (getToolSearchThreshold from main; takeActiveTodoReminder,
startActiveTodoWorkChain, startAutomaticActiveTodoWorkChain from the PR),
and diffed the result against each parent: it is exactly the union, nothing
dropped from either side.

What is load-bearing

Nothing order-sensitive: the two constants are independent, each consumed only
by its own feature (DEFAULT_TOOL_SEARCH_THRESHOLD at the toolSearchThreshold
field init; ACTIVE_TODO_REMINDER_REFRESH_TURNS in takeActiveTodoReminder).
Their relative order is irrelevant.

What I could not verify

No build/typecheck/tests were run (out of scope here). Both constants are
referenced elsewhere in the merged tree, so neither is dead. I only edited the
one conflicted file; auto-merged files were left as Git produced them. The two
features share no state, so I expect no behavioural interaction; the PR's CI is
authoritative on correctness.

中文说明

根因

main 合入了延迟工具预加载 / ToolSearch 阈值功能(tools.toolSearch.threshold
c55e63c42 一线),在 packages/core/src/config/config.ts 中新增了模块级常量
DEFAULT_TOOL_SEARCH_THRESHOLD。本 PR 在同一位置(紧跟 MEMORY_CONTEXT_WARNING_RATIO
ModelsConfig import 块之前)新增了另一个模块级常量
ACTIVE_TODO_REMINDER_REFRESH_TURNS。两个互不相关的添加落在相邻行,从而产生冲突。

文本冲突,非语义冲突

两侧仅改动相邻行,不修改任何共享逻辑。PR(保留活跃 Todo 上下文)与 main
(ToolSearch 预加载预算)是相互独立的功能。解决方式:两个常量都保留(见上方代码块)。

config.ts 是唯一冲突文件。其余文件(包括两侧都改动的 client.ts /
client.test.ts)均由 Git 自动合并,且改动区域互不重叠。我已确认合并后的
config.ts 同时包含两个功能的成员,并将结果分别与两个父提交对比:恰好是两者并集,
没有任何一侧的内容丢失。

关键依赖

此处无顺序敏感点:两个常量相互独立,各自只被自己的功能使用,先后顺序无关紧要。

未能验证的部分

未运行构建 / 类型检查 / 测试(本命令职责之外)。两个常量在合并后的代码中均被引用,
都不是死代码。我只修改了唯一冲突的文件,自动合并的文件保持 Git 生成的结果。
两个功能不共享状态,预期无行为交互,但正确性以 PR 自身 CI 为准。

@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 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.8-max-preview via Qwen Code /review

Comment on lines +6468 to +6470
todoWorkChainContext.enterWith(
this.config.getActiveTodoWorkChainOwner(promptId),
);

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.

[Suggestion] No test verifies that this daemon-path todoWorkChainContext binding is visible during tool execution — deleting these lines would pass every Session test. Concrete cost: background tasks/shells/monitors registered during daemon tool execution capture todoWorkChainContext.getStore() at registration time (background-tasks.ts:681, backgroundShellRegistry.ts:314, monitorRegistry.ts:189); without the binding they capture undefined, their terminal notifications arrive without a todoWorkChainId, are classified as unrelated automatic turns, and the active Todo reminder is silently not injected for daemon-spawned background work. The existing coreToolScheduler.test.ts coverage exercises the TUI/core scheduler path (todoWorkChainContext.run(...)), not this daemon enterWith path. Suggested fix: in the Session mid-turn test (or a new one), read todoWorkChainContext.getStore() inside the tool's execute mock and assert it equals the expected owner.

中文说明

没有任何测试验证这个 daemon 路径的 todoWorkChainContext 绑定在工具执行期间可见——删掉这几行,所有 Session 测试仍会通过。具体代价:在 daemon 工具执行期间注册的后台任务/shell/monitor 会在注册时捕获 todoWorkChainContext.getStore()background-tasks.ts:681backgroundShellRegistry.ts:314monitorRegistry.ts:189);没有这个绑定,它们捕获到 undefined,其终止通知到达时不携带 todoWorkChainId,被归类为不相关的自动轮次,active Todo reminder 就不会注入 daemon 发起的后台工作,静默失效。coreToolScheduler.test.ts 现有覆盖只验证了 TUI/core scheduler 路径(todoWorkChainContext.run(...)),没有覆盖这个 daemon enterWith 路径。建议修复:在 Session 的 mid-turn 测试(或新测试)中,于工具的 execute mock 内读取 todoWorkChainContext.getStore(),并断言它等于预期的 owner。

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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

@wenshao
wenshao added this pull request to the merge queue Jul 30, 2026
Merged via the queue into QwenLM:main with commit 2abfa3d Jul 30, 2026
75 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants