Skip to content

fix(core): redact the plan argument from history after an approved exit_plan_mode#7197

Open
zjunothing wants to merge 8 commits into
QwenLM:mainfrom
zjunothing:fix/6237-exit-plan-args-leak
Open

fix(core): redact the plan argument from history after an approved exit_plan_mode#7197
zjunothing wants to merge 8 commits into
QwenLM:mainfrom
zjunothing:fix/6237-exit-plan-args-leak

Conversation

@zjunothing

Copy link
Copy Markdown
Collaborator

What this PR does

After the user (or a plan-required teammate's leader) approves an exit_plan_mode call, the tool scheduler now rewrites the plan argument still sitting in the model turn's functionCall history entry, replacing the full plan text with a short pointer to the plan file that Config.savePlan already persisted ([Plan approved and saved to <path>. …]). The rewrite is a targeted, immutable replacement by callId via a new GeminiChat.redactApprovedPlanFromHistory method — sibling parts and other arguments (originalRequest, researchSummary) survive, the partial-push markers are unaffected (they compare by index and role), and the UI-side returnDisplay.plan is untouched. Rejected and no-action results are deliberately left alone: the rewrite is keyed off the approval llmContent prefixes (exported as a constant from exitPlanMode.ts so the two files cannot drift), because a rejected plan must stay in context for revision.

Why it's needed

#6237: after an approved exit_plan_mode, the model's later responses can contain verbatim fragments of the plan — sections, code blocks, and tables leak into answers to unrelated follow-ups ("整体完成了么?" answered with the plan text). The tool's llmContent is already clean and the plan-mode reminder is already stripped on exit; the residual leak surface, as @doudouOUC confirmed on the issue, is exactly the functionCall.args.plan blob that remains in history as the model's own prior turn. LLMs occasionally regurgitate large blocks of their own earlier tool-call arguments on long conversations. This PR implements the structural fix the maintainer proposed there: rewrite the persisted arg to a short reference after approval so the blob leaves the model's attention window, with the full text still available on disk.

Reviewer Test Plan

How to verify

  1. Start a session in plan mode, have the model call exit_plan_mode with a detailed multi-section plan, and approve it.
  2. Before this PR: every subsequent request to the provider carries the full plan text inside the assistant tool_calls arguments (visible with any request logger). After: those requests carry [Plan approved and saved to <path>. …] instead, starting with the immediate post-approval continuation.
  3. Reject the plan instead: the plan argument stays intact in history (needed for revision) — covered by a dedicated regression test.
  4. npx vitest run src/core/coreToolScheduler.test.ts src/core/geminiChat.test.ts src/tools/exitPlanMode.test.ts (packages/core): 550/550.

Evidence (Before & After)

Deterministic E2E: a PTY harness drives the real CLI (--approval-mode plan) against a local fake OpenAI-compatible server that inspects the exit_plan_mode arguments of every outgoing request. The scripted flow is user turn → exit_plan_mode tool call with a marker-bearing plan → approve in the TUI dialog → post-approval continuation → second user turn.

# Request before fix after fix
1 user turn → exit_plan_mode call plan submitted plan submitted
2 post-approval continuation full plan text present short reference
3 second user turn full plan text present short reference

verification

The new scheduler regression test ("redacts the plan argument from history after an approved exit_plan_mode") fails on the unpatched source — verified via git stash.

Tested on

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

Environment (optional)

macOS (Darwin 24.6), Node v22.23.1; PTY harness + local fake OpenAI-compatible SSE server against packages/cli/dist, plus vitest unit tests. npm run typecheck, eslint (--max-warnings 0), prettier all clean.

Risk & Scope

  • Main risk or tradeoff: after approval the model no longer sees its own plan text in context — it sees the file pointer and can re-read the plan from disk if needed. That trade (losing in-context plan detail vs. ending the regurgitation leak) is the maintainer-proposed direction on the issue. The rewrite runs before setStatusInternal('success') so even the synchronously-submitted continuation turn sees sanitized history, and it is wrapped in try/catch — any failure logs a warning and leaves history unchanged.
  • Not validated / out of scope: the chat-recording JSONL keeps the original functionCall args, so a --resumed session re-feeds the full plan text until its next approved exit — sanitizing the recording/resume path is left as a follow-up (flagged in the commit message). For plan-required teammates the scheduler reaches the main session's chat, so the teammate's own history keeps its plan (the rewrite no-ops on an unmatched callId); the main-session leak this issue reports is fully covered.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #6237

中文说明

本 PR 做了什么

在用户(或 plan-required teammate 的 leader)批准 exit_plan_mode 之后,工具调度器改写仍留在模型轮次 functionCall 历史条目里的 plan 参数,用指向 Config.savePlan 已持久化的 plan 文件的短引用([Plan approved and saved to <path>. …])替换完整 plan 文本。改写通过新的 GeminiChat.redactApprovedPlanFromHistorycallId 定向、不可变地替换——同级 parts 与其它参数(originalRequestresearchSummary)保留,partial-push 标记不受影响(按索引与角色比对),UI 侧 returnDisplay.plan 不动。被拒绝/未生效的结果有意不改写:改写以批准的 llmContent 前缀为键(从 exitPlanMode.ts 导出常量,两个文件不会漂移),因为被拒的 plan 必须留在上下文里供修订。

为什么需要

#6237:批准 exit_plan_mode 后,模型后续回复会包含 plan 的逐字片段——章节、代码块、表格泄漏进无关追问的回答(「整体完成了么?」被用 plan 文本回答)。工具的 llmContent 本就干净、plan-mode reminder 退出时已剥离;正如 @doudouOUC 在 issue 中确认的,残留泄漏面恰是历史里作为模型自身前轮的 functionCall.args.plan 大文本——长对话中 LLM 偶尔会复读自己早前 tool-call 参数的大段内容。本 PR 落地 maintainer 在 issue 中提出的结构性修复:批准后把持久化参数改写为短引用,使大文本离开模型注意力窗口,完整文本仍在磁盘可查。

审阅测试计划

如何验证

  1. plan 模式会话中让模型用多章节详细 plan 调用 exit_plan_mode 并批准;
  2. 本 PR 之前:其后每个发往 provider 的请求都在 assistant tool_calls 参数里携带完整 plan 文本;之后:从批准后的即时 continuation 起改为短引用;
  3. 改为拒绝:plan 参数在历史中保持原样(供修订)——有专门回归测试覆盖;
  4. 三个测试文件 550/550。

证据(Before & After)

确定性 E2E(PTY 驱动真实 CLI --approval-mode plan + 本地伪 OpenAI 服务器检查每个请求的 exit_plan_mode 参数):修复前 #2 批准后 continuation 与 #3 第二个用户轮次均带完整 plan 文本(❌);修复后均为短引用(✅)。新增的调度器回归测试在未修复源码上失败(经 git stash 验证)。

测试平台

macOS 已本地验证(✅);Windows / Linux 依赖 CI(⚠️)。

环境

macOS(Darwin 24.6)、Node v22.23.1;PTY harness + 本地伪 OpenAI SSE 服务器 + vitest;typecheck / eslint / prettier 全绿。

风险与范围

  • 主要风险/权衡:批准后模型不再于上下文中看到自己的 plan 文本——它看到文件指针,需要时可从磁盘重读。这一取舍(失去上下文内 plan 细节 vs 终结复读泄漏)正是 issue 中 maintainer 提出的方向。改写在 setStatusInternal('success') 之前执行,同步提交的 continuation 轮次即已看到净化后的历史;整体包在 try/catch 中,失败只记 warning、历史保持不变。
  • 未验证/超出范围:chat-recording JSONL 保留原始 functionCall 参数,--resume 的会话在下一次批准退出前会重新带回完整 plan 文本——录制/恢复路径的净化留作后续(已在提交信息中注明)。plan-required teammate 场景中调度器触达的是主会话 chat,teammate 自身历史保留其 plan(callId 不匹配时改写为 no-op);本 issue 报告的主会话泄漏已完整覆盖。
  • 破坏性变更/迁移说明:无。

关联 Issue

Fixes #6237

🤖 Generated with Claude Code

…it_plan_mode

The full plan text a model submits to exit_plan_mode stays in the
conversation history as its own functionCall arguments. On long
conversations models occasionally regurgitate chunks of that blob into
later responses, mixing stale plan sections into answers (QwenLM#6237).

After an APPROVED exit (keyed off the approval llmContent prefixes, so
rejected/no-action results keep their plan text for revision), the tool
scheduler now swaps the `plan` argument in the model turn's functionCall
for a short pointer to the plan file that Config.savePlan already
persisted. The rewrite is a targeted, immutable replacement by callId in
GeminiChat history: sibling parts and other arguments survive, and the
partial-push markers are unaffected (they compare by index and role).
returnDisplay.plan on the UI side is untouched.

Verified with a PTY E2E against a local fake OpenAI-compatible server
inspecting the exit_plan_mode arguments of every outgoing request:
before, the full plan text rides along on every post-approval request;
after, those requests carry only the reference. The scheduler regression
test fails on the unpatched source. Known limit: the chat-recording
JSONL keeps the original args, so a resumed session re-feeds the full
plan text until its next approved exit.

Fixes QwenLM#6237

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zjunothing

Copy link
Copy Markdown
Collaborator Author

Verification report

E2E (PTY drives the real packages/cli/dist bundle in --approval-mode plan against a local fake OpenAI-compatible server that parses the exit_plan_mode arguments out of every /chat/completions request):

# request before (origin/main 7b1714489) after (this PR)
1 user turn → exit_plan_mode call plan submitted plan submitted
2 post-approval continuation FULL-PLAN-PRESENT REDACTED-REFERENCE
3 second user turn FULL-PLAN-PRESENT REDACTED-REFERENCE

Row 2 matters: the rewrite runs before setStatusInternal('success'), so even the continuation submitted synchronously by the completion callback already sees sanitized history.

verification

Tests (packages/core, 550/550 across the three touched files):

  • redacts the plan argument from history after an approved exit_plan_mode — full scheduler path with a real GeminiChat; fails on unpatched source (verified via git stash: the plan text is still present).
  • keeps the plan argument in history when exit_plan_mode is not approved — rejection keeps the plan for revision.
  • 3 unit tests on GeminiChat.redactApprovedPlanFromHistory: targeted rewrite preserves sibling parts and other args; returns false on unmatched id/name and on a missing string plan arg.

Static checks: npm run typecheck ✅ · eslint --max-warnings 0 ✅ · prettier ✅.

Review focus suggestions: (1) the approval detection — keyed off PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES exported from exitPlanMode.ts, which must stay in lockstep with the tool's success returns (a comment on the constant pins this); (2) the immutable replacement in redactApprovedPlanFromHistory — entry replaced at the same index, so the partial-push markers (index+role comparison) cannot desync; (3) known limit, stated in the PR: the chat-recording JSONL keeps the original args, so a --resumed session re-feeds the full plan until its next approved exit — happy to do the recording-side sanitize as a follow-up if wanted.

中文版本

E2E(PTY 驱动真实 CLI --approval-mode plan + 本地伪 OpenAI 服务器解析每个请求里的 exit_plan_mode 参数):修复前批准后的 continuation(#2)与第二个用户轮次(#3)均携带完整 plan 文本;修复后均为短引用。#2 尤其关键:改写在 setStatusInternal('success') 之前执行,完成回调同步提交的 continuation 已看到净化后的历史。

测试(packages/core 三文件 550/550):调度器回归测试「批准后改写 plan 参数」在未修复源码上失败(git stash 验证);「未批准时保留 plan 参数」覆盖拒绝路径;redactApprovedPlanFromHistory 3 个单测覆盖定向替换、不匹配返回 false、无字符串 plan 参数返回 false。静态检查全绿。

审阅要点:(1) 批准判定依赖 exitPlanMode.ts 导出的 PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES,需与工具成功返回保持同步(常量上有注释固定);(2) 历史条目按同索引不可变替换,partial-push 标记(按索引+角色比对)不会失步;(3) 已声明的已知局限:chat-recording JSONL 保留原始参数,--resume 会话在下次批准退出前会重新带回完整 plan——如需要,愿意后续补录制侧净化。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with solid evidence. #6237 documents the plan text leaking into subsequent model responses after exit_plan_mode approval, confirmed by @doudouOUC. The PR includes deterministic E2E before/after with a PTY harness and fake API server showing the leak and the fix.

Direction: aligned. This is the structural fix the maintainer proposed on the issue — replace the in-context plan blob with a file pointer after approval. Clean trade: plan text leaves the attention window, full text stays on disk via Config.savePlan.

Size: core paths touched (coreToolScheduler.ts, geminiChat.ts, exitPlanMode.ts) — ~100 production lines + ~200 test lines. Well under the 500-line threshold. Not applicable for escalation.

Approach: the scope feels right — one focused change, no scope creep. The three-layer separation is clean: the approval-detection constant lives in exitPlanMode.ts (single source of truth), the history rewrite is a targeted GeminiChat method (immutable replacement by callId), and the scheduler glues them together before setStatusInternal with a try/catch safety net. Rejected plans are deliberately left untouched for revision.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有充分证据。#6237 记录了 exit_plan_mode 批准后 plan 文本泄漏到后续模型回复中,@doudouOUC 已确认。PR 提供了确定性 E2E before/after(PTY + 伪 API 服务器),展示了泄漏和修复效果。

方向:对齐。这正是 maintainer 在 issue 中提出的结构性修复——批准后用文件指针替换上下文中的 plan 大文本。权衡合理:plan 文本离开注意力窗口,完整文本通过 Config.savePlan 保留在磁盘。

规模:触及核心路径(coreToolScheduler.tsgeminiChat.tsexitPlanMode.ts)——约 100 行生产代码 + 约 200 行测试。远低于 500 行阈值,不需要升级。

方案:范围合理——专注一个改动,无范围蔓延。三层分离清晰:批准判定常量在 exitPlanMode.ts(单一事实来源),历史改写是 GeminiChat 上的定向方法(按 callId 不可变替换),调度器在 setStatusInternal 之前将它们串联并加 try/catch 安全网。被拒的 plan 有意保留原文供修订。

进入代码审查 🔍

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): I'd add a targeted method on GeminiChat to find an exit_plan_mode functionCall by its call ID and immutably replace the plan arg with a short file reference. The scheduler would detect approval in the success handler (tool name + llmContent prefix match) and invoke this method before marking the tool as done, wrapped in try/catch. The approval-detection prefixes should be a shared constant to prevent drift.

The PR's implementation matches this exactly, and handles edge cases I'd have missed:

  • Immutable replacement at same indexthis.history[i] = { ...entry, parts: newParts } preserves array length, so partial-push markers (index+role comparison) cannot desync.
  • Backward iteration — searches from the end of history, finding the most recent matching call.
  • Dual guard — checks both callId and ToolNames.EXIT_PLAN_MODE, so an unrelated tool with a coincidental plan arg is never touched.
  • Shared constantPLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES exported from exitPlanMode.ts with a comment pinning the contract. I verified the two prefixes ('User approved.', 'Leader approved.') match the actual success returns at lines 250 and 346, and that the error case at line 338 ('Leader approved the plan, but failed...') correctly routes through toolResult.error and never reaches the redaction code.
  • Graceful degradationgetGeminiClient?.()?.getChat()?.redactApprovedPlanFromHistory(...) no-ops when there's no client or chat, and the outer try/catch logs a warning on any failure.

No correctness bugs, security holes, or AGENTS.md violations found.

Tests

 ✓ src/tools/exitPlanMode.test.ts (28 tests) 37ms
 ✓ src/core/coreToolScheduler.test.ts (282 tests) 1853ms
 ✓ src/core/geminiChat.test.ts (240 tests) 11862ms

 Test Files  3 passed (3)
      Tests  550 passed (550)
  • typecheck ✅ clean
  • eslint --max-warnings 0 ✅ clean on all 3 production files

Real-Scenario Testing

The core behavior (plan text redaction from API request payloads) is not TUI-visible — it requires inspecting HTTP traffic, which the author did with a PTY harness + fake API server (evidence in the PR body). The tmux test here is a CLI smoke test confirming both builds are functional:

Before (installed build)

runner@runnervm3jd5f:~/work/qwen-code/qwen-code$ qwen -p 'say hello in one word' 2>&1 | head -20
Hello
runner@runnervm3jd5f:~/work/qwen-code/qwen-code$

After (this PR via npm run dev)

runner@runnervm3jd5f:~/work/qwen-code/qwen-code/.qwen/worktrees/triage$ npm run dev -- -p 'say hello in one word' 2>&1 | head -20

> @qwen-code/qwen-code@0.19.12 dev
> node scripts/dev.js -p say hello in one word

Hello
runner@runnervm3jd5f:~/work/qwen-code/qwen-code/.qwen/worktrees/triage$

Both builds respond correctly. CI Test (ubuntu-latest, Node 22.x) is pending at time of review.

中文说明

代码审查

独立方案(未看 diff 前):在 GeminiChat 上新增定向方法,按 call ID 找到 exit_plan_mode 的 functionCall,不可变地将 plan 参数替换为短文件引用。调度器在成功处理中检测批准(工具名 + llmContent 前缀匹配),在标记工具完成前调用此方法,包 try/catch。批准检测前缀应为共享常量以防漂移。

PR 实现完全匹配,且处理了我会遗漏的边界情况:

  • 同索引不可变替换 — 保持数组长度不变,partial-push 标记(按索引+角色比对)不会失步。
  • 反向遍历 — 从历史末尾搜索,找到最近匹配的调用。
  • 双重守卫 — 同时检查 callIdToolNames.EXIT_PLAN_MODE,不会误触其他工具的 plan 参数。
  • 共享常量PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXESexitPlanMode.ts 导出,注释固定了契约。已验证两个前缀与实际成功返回匹配,错误情况通过 toolResult.error 路由,不会触发改写。
  • 优雅降级 — 无客户端或 chat 时 no-op,外层 try/catch 失败只记 warning。

未发现正确性 bug、安全漏洞或 AGENTS.md 违规。

测试

三文件 550/550 通过。typecheckeslint --max-warnings 0 全绿。

真实场景测试

核心行为(API 请求载荷中的 plan 文本改写)不在 TUI 可见——需要检查 HTTP 流量,作者已通过 PTY + 伪 API 服务器完成(证据见 PR 正文)。此处 tmux 测试为 CLI 冒烟测试,确认两个版本均可正常工作。CI Test (ubuntu-latest, Node 22.x) 在审阅时尚未完成。

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; focused fix for a confirmed bug with correct implementation and thorough test coverage.

This is a well-scoped structural fix that does exactly what the maintainer proposed on #6237. The approach — replacing the in-context plan blob with a file pointer after approval — is the right trade: the plan text leaves the model's attention window, the full text remains on disk via Config.savePlan, and the model can re-read it if needed.

The implementation is tight: ~100 production lines across 3 files, no scope creep, no unnecessary abstractions. The three-layer design (constant in exitPlanMode.ts → method on GeminiChat → integration in scheduler) gives clean separation of concerns and makes the contract between files explicit and drift-resistant. The try/catch safety net and graceful no-op when there's no client are the right defensive choices for a non-critical enhancement.

Tests cover both the happy path (approved → redacted) and the rejection path (kept for revision), at both the unit level (GeminiChat.redactApprovedPlanFromHistory) and the integration level (full scheduler with a real GeminiChat). The author's E2E evidence (PTY harness + fake API server, before/after table) properly demonstrates the fix at the API payload level where the bug actually manifests.

The known limitation (chat-recording JSONL keeps original args, so --resume re-feeds the full plan until next approval) is honestly declared and reasonable to defer.

LGTM. ✅

中文说明

置信度:5/5 — 各阶段均通过;聚焦修复已确认的 bug,实现正确,测试覆盖充分。

这是一个范围合理的结构性修复,完全落地了 maintainer 在 #6237 中提出的方案——批准后用文件指针替换上下文中的 plan 大文本。权衡合理:plan 文本离开模型注意力窗口,完整文本通过 Config.savePlan 保留在磁盘,模型需要时可重读。

实现紧凑:3 个文件约 100 行生产代码,无范围蔓延,无不必要的抽象。三层设计(exitPlanMode.ts 中的常量 → GeminiChat 上的方法 → 调度器集成)关注点分离清晰,文件间契约显式且抗漂移。try/catch 安全网和无客户端时的优雅 no-op 是对非关键增强的正确防御选择。

测试覆盖了批准路径(改写)和拒绝路径(保留供修订),包括单测(GeminiChat.redactApprovedPlanFromHistory)和集成测试(完整调度器 + 真实 GeminiChat)。作者的 E2E 证据(PTY + 伪 API 服务器,before/after 表格)在 bug 实际发生的 API 载荷层正确展示了修复效果。

已知局限(chat-recording JSONL 保留原始参数,--resume 在下次批准前会重新带回完整 plan)诚实声明,合理延后。

LGTM. ✅

Qwen Code · qwen3.7-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot added category/core Core engine and logic type/bug Something isn't working as expected labels Jul 19, 2026

@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: verification — a verifier ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and the posted findings cannot be counted as verified against it.

— qwen3.7-max via Qwen Code /review

Comment on lines +4358 to +4363
try {
const planPath = this.config.getPlanFilePath();
this.config
.getGeminiClient?.()
?.getChat()
.redactApprovedPlanFromHistory(

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] The redaction rewrites the in-memory GeminiChat.history array, but the chat-recording JSONL (append-only) already captured the full plan text when recordAssistantTurn fired during the model's original response — before tool execution and before this redaction. A --continue / --resume of the same session rehydrates the full plan text from the JSONL, re-introducing the very leak (#6237) this fix addresses in the current session.

Failure scenario: User enters plan mode, model produces a large plan, user approves. In the current session the redaction works correctly. User later runs qwen --continue on the same session — the JSONL is replayed into history with the original full plan text. On a long follow-up conversation, the model regurgitates plan chunks again.

Suggested fix: Apply redactApprovedPlanFromHistory at JSONL load time in the --resume path (scan for approved exit_plan_mode calls and replace their plan args), or add a recording-level redaction alongside the in-memory one.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Implemented in c054057, at load time as suggested — but at the choke point rather than per resume path: GeminiChat.setHistory and the constructor now run a history-wide pass (redactApprovedPlansInHistory, pure and unit-tested) that rewrites approved exit_plan_mode calls, so every rehydration route (--resume, --continue, ACP session load) is covered by the same two entry points. It applies the same never-lie rule as the write side: only calls whose plan matches the current on-disk plan file are rewritten. The pointer text moved into a shared approvedPlanRedactionText helper so the write and load surfaces cannot drift. One stated bound: with several approved plans in one session the file holds only the last, so earlier calls rehydrate unredacted (safe — the file never mismatches the pointer). Wiring pinned by tests with and without the plan file present, plus a constructor-path test.

中文:已在 c054057 按建议在加载侧实现——但收在 choke point 而非逐个 resume 路径:GeminiChat.setHistory 与构造器统一跑历史级净化(纯函数 redactApprovedPlansInHistory),覆盖 --resume/--continue/ACP 加载全部路径;与写侧同一「永不说谎」规则(仅当 plan 与当前磁盘文件一致才改写);指针文案抽到共享 approvedPlanRedactionText 防两面漂移。已声明的边界:一个会话多次批准时文件只存最后一份,较早的调用恢复时不改写(安全,只是不最小化)。接线由有/无 plan 文件两组测试 + 构造器路径测试固定。

Comment on lines +4359 to +4367
const planPath = this.config.getPlanFilePath();
this.config
.getGeminiClient?.()
?.getChat()
.redactApprovedPlanFromHistory(
callId,
`[Plan approved and saved to ${planPath}. The plan text was ` +
`removed from the conversation after approval; read that ` +
`file if you need to consult it again.]`,

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] The redaction message claims "Plan approved and saved to <path>" but savePlanBestEffort in ExitPlanModeToolInvocation.execute swallows all filesystem errors (disk full, permissions). When save fails silently, the plan text is redacted from the only remaining in-memory copy and replaced with a pointer to a file that does not exist.

Failure scenario: Filesystem runs out of space. User approves the plan. savePlan throws ENOSPC, savePlanBestEffort logs a warning and returns. The redaction block still fires (because llmContent starts with "User approved."). The plan text in history is replaced with a pointer to a non-existent file. If the model later tries to read_file at that path, the file is not there.

Suggested fix: Have savePlanBestEffort return a boolean indicating success, and gate the redaction on that flag — or change the replacement string to not assert the file was saved when it was not.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c054057. The scheduler now reads the plan file before redacting, and redactApprovedPlanFromHistory gained an expectedPlan gate — the rewrite only happens when the in-history plan equals the on-disk content byte-for-byte. A missing, unreadable, or stale file (earlier plan overwritten scenario included) skips the redaction with a warning, so the pointer can never claim a save that failed. New regression test: approved exit with a non-existent plan file → plan text stays in history.

中文:已在 c054057 修复。调度器改写前先读 plan 文件,redactApprovedPlanFromHistory 新增 expectedPlan 门——仅当历史中的 plan 与磁盘内容逐字节一致才改写;文件缺失/不可读/内容陈旧则跳过并告警,指针永不谎称保存成功。新增回归测试:批准但 plan 文件不存在 → 历史保留原文。

…story load

Review follow-up on QwenLM#7197, addressing both inline findings.

Pointer honesty: savePlanBestEffort swallows filesystem errors, so the
scheduler previously could replace the only remaining in-memory plan
with a pointer to a file that was never written. The redaction now reads
the plan file first and redactApprovedPlanFromHistory requires the
in-history plan to equal the on-disk content byte-for-byte — a missing,
unreadable, or stale file skips the rewrite entirely.

Resume leak: the chat-recording JSONL captures the assistant turn (full
plan argument) before the tool runs, so --resume/--continue re-fed the
text the in-session redaction removed. GeminiChat.setHistory and the
constructor now run a history-wide pass (redactApprovedPlansInHistory)
that rewrites approved exit_plan_mode calls under the same
file-must-match rule. The pointer text moved to a shared
approvedPlanRedactionText helper so the write and load sides cannot
drift. Known bound, stated in code: with several approved plans in one
session the file holds only the last, so earlier calls rehydrate
unredacted — safe, just not minimal.

New tests: scheduler save-failure case (plan retained), pure-function
matrix (approval detection, stale-file guard, non-approval), and
setHistory/constructor wiring both with and without the plan file.
557/557 across the three touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zjunothing

Copy link
Copy Markdown
Collaborator Author

Both inline findings addressed in c054057 (per-thread replies posted): (1) the redaction is now gated on the on-disk plan file matching the in-history text byte-for-byte — a swallowed save failure or stale file skips the rewrite, so the pointer can never lie; (2) the resume leak is closed at the load choke point — GeminiChat.setHistory + constructor re-apply the redaction to approved calls via a pure, unit-tested history-wide pass sharing the same pointer text and the same file-must-match rule. 557/557 across the three touched suites; typecheck / eslint / prettier clean.

中文:两条行内发现均已在 c054057 落地(已逐 thread 回复):(1) 改写以「磁盘 plan 文件与历史文本逐字节一致」为门——保存静默失败或文件陈旧则跳过,指针永不说谎;(2) resume 泄漏在加载 choke point 关闭——setHistory 与构造器经纯函数历史级净化重放改写,与写侧共享文案与同一校验规则。三套件 557/557,静态检查全绿。

@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 on lines +186 to +191
/**
* Single source of the pointer text that replaces an approved plan's
* `functionCall.args.plan` (#6237). Shared by the tool scheduler's
* post-approval rewrite and the load-side pass below so the two surfaces
* cannot drift.
*/

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] These two new functions (approvedPlanRedactionText, redactApprovedPlansInHistory) are inserted between the pre-existing JSDoc block that documented redactStructuredOutputArgsForRecording and that function itself. TypeScript attaches a doc comment to the next declaration, so with two adjacent /** … */ blocks the first one is orphaned: redactStructuredOutputArgsForRecording silently loses its documentation, and the orphaned "Exported for tests; callers should prefer the inline use inside recordAssistantTurn" guidance now visually precedes approvedPlanRedactionText — which production code (the scheduler and the load-side pass) does call, making the guidance misleading. — Concrete cost: lost/misleading API docs for a recording helper.

Fix: move the two new exported functions below redactStructuredOutputArgsForRecording so the existing doc comment stays attached to its function.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 566dba2 — the two helpers moved below redactStructuredOutputArgsForRecording, so its doc comment is attached to it again and the "prefer the inline use" guidance no longer visually precedes a function that production code does call. 中文:已移到该函数之后,文档注释重新归位,误导性引导消除。

Comment on lines +11394 to +11396
expect(chat.redactApprovedPlanFromHistory('call-plan', REPLACEMENT)).toBe(
true,
);

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] The expectedPlan mismatch branch of redactApprovedPlanFromHistory — the if (expectedPlan !== undefined && plan !== expectedPlan) return false guard — has no test at any level: every unit call here omits the third argument, and the scheduler tests only cover a missing file (the read throws), not a file whose content differs from the in-history plan. — Failure scenario: if a refactor inverts the comparison to plan === expectedPlan, the scheduler would redact the plan and point the model at a file holding a different plan, violating the "never lie" invariant the code comments describe, and no test would fail.

Fix: add a unit test, e.g. expect(chat.redactApprovedPlanFromHistory('call-plan', REPLACEMENT, 'a different plan')).toBe(false), and assert the plan arg is left unchanged.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 566dba2 — unit test drives the expectedPlan mismatch branch directly: a stale/different third argument returns false and leaves the plan arg unchanged, then the same call with the matching plan still rewrites (guards both the comparison direction and the never-lie invariant). 中文:已补单测——不一致的 expectedPlan 返回 false 且历史不变,随后一致的 plan 仍可改写;比较方向反转会被立刻抓住。

]);
}

it('redacts the plan argument from history after an approved exit_plan_mode', async () => {

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] PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES includes 'Leader approved.', but no test exercises that prefix — every approval-path test (including this one) uses 'User approved.' as the llmContent. — Failure scenario: if the leader prefix string changes (e.g. to 'Leader has approved.') or is dropped from the constant, the scheduler's .some(prefix => …) match stops firing for leader/teammate-approved exits, so the plan stays in history unredacted for that path — silently re-introducing the plan-history leak this PR fixes — and no test would catch it.

Fix: add one test mirroring this one but with llmContent: 'Leader approved. You can now start coding…', asserting the plan arg is redacted.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 566dba2 — a scheduler-path test mirroring the user-approved one but with llmContent: 'Leader approved. …', asserting the plan is redacted to the file pointer. Dropping or renaming the leader prefix in PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES now fails a test. 中文:已补 leader 前缀的调度器测试;该前缀被改名/删除会直接跑挂测试。

…phaned doc

Review follow-up on QwenLM#7197 (three inline findings): move the two plan-
redaction helpers below redactStructuredOutputArgsForRecording so its
doc comment stays attached; add the expectedPlan-mismatch unit test
(stale on-disk plan blocks the rewrite, matching plan still rewrites);
add a scheduler test for the 'Leader approved.' llmContent prefix so the
teammate approval path cannot silently drop out of the redaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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/geminiChat.ts Outdated
Comment on lines +186 to +189
/**
* Replaces the args on a `structured_output` `functionCall` with the
* same `__redacted` placeholder used by `ToolCallEvent` telemetry
* (`packages/core/src/telemetry/types.ts`).

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] The diff inserts a duplicate of the redactStructuredOutputArgsForRecording JSDoc block — the original copy (lines 166–185, unchanged context) already precedes that function, and this second identical copy sits between it and the new approvedPlanRedactionText. Both copies are now orphaned: neither is adjacent to redactStructuredOutputArgsForRecording (line ~283). — Concrete cost: IDE doc-tooltips and typedoc attach the structured_output redaction docs to approvedPlanRedactionText instead; a future maintainer editing the docs updates the wrong copy.

Suggested change
/**
* Replaces the args on a `structured_output` `functionCall` with the
* same `__redacted` placeholder used by `ToolCallEvent` telemetry
* (`packages/core/src/telemetry/types.ts`).
/**
* Single source of the pointer text that replaces an approved plan's
* `functionCall.args.plan` (#6237). Shared by the tool scheduler's
* post-approval rewrite and the load-side pass below so the two surfaces
* cannot drift.
*/

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in df52153 — both stranded copies removed and the single doc block reattached directly above redactStructuredOutputArgsForRecording; the helpers keep their own docs. 中文:两份孤儿副本已删,唯一文档块归位到函数正上方。

Comment on lines +11501 to +11503
const approvedHistory = (): Content[] => [
{ role: 'user', parts: [{ text: 'plan it' }] },
{

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] redactApprovedPlansInHistory is only tested with a single exit_plan_mode call in history — the Set-based multi-call path and the per-ID approved.has(fc.id) gate are never exercised with more than one element. — Failure scenario: if a future refactor replaces the approved Set with a boolean flag or drops the approved.has(fc.id) check, every existing test still passes, but a history with an approved and a rejected call sharing the same plan text would incorrectly redact the rejected call — destroying the plan text the model needs for revision.

Add one test with two exit_plan_mode calls: one approved (matching the on-disk plan) and one rejected (same plan text). Assert the approved call is redacted and the rejected call is untouched.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in df52153 — a history with a rejected and an approved exit_plan_mode call sharing the same plan text: only the approved call is rewritten, the rejected one keeps its text for revision. This pins the per-call-id approved.has(fc.id) gate against a boolean-flag refactor. 中文:已补「同 plan 文本的拒绝+批准双调用」测试——仅批准调用被改写,钉死 per-call-id 门。

const fc = part.functionCall;
if (!fc?.id || fc.name !== ToolNames.EXIT_PLAN_MODE) return part;
if (!approved.has(fc.id)) return part;
if ((fc.args ?? {})['plan'] !== savedPlanContent) return part;

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] Load-side redaction only matches the last approved plan — earlier approved plans rehydrate unredacted on --resume, re-opening the #6237 regurgitation for multi-plan sessions. — Failure scenario: a session approves plan A, then plan B (overwriting A on disk). On --resume, plan A's functionCall fails the !== savedPlanContent check and rehydrates with the full plan blob in the model's context. The code comment acknowledges this ("earlier calls rehydrate unredacted; safe, just not minimal"), but the original issue is not fully fixed for this case.

Consider persisting each approved plan under a per-call-id file (e.g. <sessionId>-<callId>.md) so the load-side pass can match every approved call against its own file.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferring this one deliberately (stated bound in code/PR): per-call-id plan files change the Config.savePlan single-file contract — getPlanFilePath() / loadPlan() are shared with other plan consumers, so the layout change belongs in its own reviewed change rather than growing this fix. The current behavior is safe (never lies, never redacts against a mismatched file); the cost is only that earlier plans in multi-approval sessions rehydrate unredacted. Happy to open a follow-up issue with the per-call-id file design if maintainers want it tracked. 中文:此条有意留作 follow-up——per-call-id 文件改变 savePlan 单文件契约(getPlanFilePath/loadPlan 有其他消费方),应独立评审而非扩大本修复。现行为安全(永不说谎、不对不匹配文件改写),代价仅是多次批准会话中较早的 plan 恢复时不被改写。如维护者希望跟踪,可开 follow-up issue 附设计。

Comment thread packages/core/src/core/geminiChat.ts Outdated
if (!entry?.parts) continue;
for (const part of entry.parts) {
const fr = part.functionResponse;
if (!fr?.id || fr.name !== ToolNames.EXIT_PLAN_MODE) continue;

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] The load-side redactApprovedPlansInHistory compares fr.name / fc.name directly against ToolNames.EXIT_PLAN_MODE, while the write-side scheduler resolves through canonicalToolName(reqInfo.name). The two surfaces handle tool-name migration inconsistently. — Failure scenario: if exit_plan_mode is ever added to ToolNamesMigration (e.g. renamed to exit_plan), the in-session scheduler redaction still fires, but the load-side pass silently stops matching for sessions recorded under the old name — the --resume leak this PR fixes reappears with no error or warning.

Suggested change
if (!fr?.id || fr.name !== ToolNames.EXIT_PLAN_MODE) continue;
if (!fr?.id || canonicalToolName(fr.name) !== ToolNames.EXIT_PLAN_MODE) continue;

(and the same for fc.name at line 262)

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied in df52153 — every load-side name match (functionResponse collection, functionCall rewrite, the instance method, and the cheap pre-scan) now routes through a local ToolNamesMigration mirror (canonicalPlanToolName). Kept local rather than importing the scheduler's canonicalToolName because that would create a geminiChat → coreToolScheduler import cycle (the scheduler already imports approvedPlanRedactionText from geminiChat); the mirror reads the same migration map, so the two cannot diverge on data. 中文:已在读侧所有 name 匹配点应用 ToolNamesMigration 本地镜像;不直接 import 调度器的 canonicalToolName 是因为会形成 geminiChat→scheduler 循环依赖(调度器已从 geminiChat 导入指针文案),镜像读同一迁移表,数据上不会分叉。

…ol names on load

Review follow-up on QwenLM#7197 (second inline round): remove the duplicated
redactStructuredOutputArgsForRecording JSDoc copies stranded by the
earlier helper move and reattach the single copy to its function; route
every load-side tool-name match through a local ToolNamesMigration
mirror (canonicalPlanToolName — kept local to avoid a geminiChat ->
coreToolScheduler import cycle) so a future exit_plan_mode rename keeps
the resume-time redaction working for sessions recorded under the old
name; add the approved+rejected same-plan-text regression test pinning
the per-call-id approval gate. The per-call-id plan *file* suggestion is
deferred as a follow-up — it changes the Config.savePlan single-file
contract shared with other plan consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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/geminiChat.ts Outdated
Comment on lines +166 to +171
/**
* Single source of the pointer text that replaces an approved plan's
* `functionCall.args.plan` (#6237). Shared by the tool scheduler's
* post-approval rewrite and the load-side pass below so the two surfaces
* cannot drift.
*/

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] This JSDoc block ("Single source of the pointer text that replaces an approved plan's functionCall.args.plan…") describes approvedPlanRedactionText but is physically attached to canonicalPlanToolName — which has its own JSDoc immediately below — so two consecutive doc blocks precede one function, while the exported approvedPlanRedactionText (depended on by the scheduler write-side at coreToolScheduler.ts:4373 and the load-side pass at geminiChat.ts:249) has none. — Concrete cost: a developer tracing the redaction pointer text reads this block, expects the next declaration to be approvedPlanRedactionText, and instead finds a name-resolution helper, obscuring the actual single source of truth and making it easier for a future edit to change the format string without realizing two call sites rely on it.

Fix: move this JSDoc block down to directly precede export function approvedPlanRedactionText(planPath: string): string { (spans two locations, so no one-click suggestion block).

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed — the pointer-text doc now directly precedes approvedPlanRedactionText; canonicalPlanToolName keeps only its own doc. 中文:文案文档已归位到其函数正上方。

Comment on lines +3706 to +3713
try {
planPath = this.config.getPlanFilePath();
savedPlan = fs.readFileSync(planPath, 'utf-8');
} catch {
// No plan file (never saved, or save failed): leave history alone —
// never swap plan text for a pointer to a file that is not there.
return;
}

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] This load-side catch swallows the filesystem error with no runtime log, while the write-side scheduler path logs via debugLogger.warn on the same failure class. — Concrete cost: on --resume with a removed/unsaved plan file, readFileSync throws, the redaction silently skips, and the full plan text re-enters the model's context (the #6237 symptom); an oncall engineer enabling DEBUG=* sees no trace the redaction was even attempted and must read the source to discover this silent return path.

Suggested change
try {
planPath = this.config.getPlanFilePath();
savedPlan = fs.readFileSync(planPath, 'utf-8');
} catch {
// No plan file (never saved, or save failed): leave history alone —
// never swap plan text for a pointer to a file that is not there.
return;
}
try {
planPath = this.config.getPlanFilePath();
savedPlan = fs.readFileSync(planPath, 'utf-8');
} catch (err) {
// No plan file (never saved, or save failed): leave history alone —
// never swap plan text for a pointer to a file that is not there.
debugLogger.debug(
`Skipping load-side plan redaction, plan file unavailable: ${err}`,
);
return;
}

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied as suggested — the load-side catch now logs Skipping load-side plan redaction, plan file unavailable: … at debug level, matching the write-side scheduler's traceability. 中文:已按建议在 catch 中加 debug 日志,与写侧对齐。

…ed load-side redaction

Review follow-up on QwenLM#7197 (third inline round): the "single source of
the pointer text" JSDoc landed above canonicalPlanToolName instead of
approvedPlanRedactionText — moved to its function; the load-side catch
now emits a debugLogger.debug line when the plan file is unavailable so
a --resume that silently skips the redaction is traceable under DEBUG,
matching the write-side scheduler's logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review paused — model quota exhausted. Qwen review stopped: the model API quota is exhausted (reset at 07-20 07:32:00 UTC.). Transient errors auto-retry, but a quota reset is too far out to wait on a runner. Re-run once it resets by commenting @qwen-code /review. See workflow logs.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/29743366222)._

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Code Review — fix(core): redact the plan argument from history after an approved exit_plan_mode (#7197)

Overview

After an approved exit_plan_mode, the full plan text remains in history as the model's own functionCall.args.plan, and long conversations regurgitate chunks of it into unrelated answers (#6237). This PR swaps that blob for a short pointer to the saved plan file, on two surfaces:

  • Write sideCoreToolScheduler detects an approved exit (by llmContent prefix) and, before setStatusInternal('success') so the synchronous continuation turn already sees clean history, calls the new GeminiChat.redactApprovedPlanFromHistory(callId, pointer, savedPlan).
  • Load sideredactApprovedPlansFromLoadedHistory() runs in the GeminiChat constructor and in setHistory(), re-applying the redaction on --resume/--continue/compression via the pure redactApprovedPlansInHistory().

Both are gated on a never-lie invariant: the pointer is only written when the on-disk plan file exists and its bytes equal the in-history plan. I verified Config.savePlan writes the plan verbatim (writeFileSync(tmp, plan) → rename), so the byte-for-byte gate can actually pass in the normal flow.

Strengths

  • Genuinely defensive. Immutable replacement at the same index (preserves partial-push markers, which compare by index+role), try/catch around the whole write-side block, idempotent load-side (already-redacted args no longer equal savedPlan, so re-runs no-op), and a savedPlan === plan gate that fails safe (skips redaction) whenever the save failed or the file is stale.
  • Import-cycle awareness. geminiChat deliberately avoids importing coreToolScheduler; the canonicalPlanToolName mirror and the shared approvedPlanRedactionText / PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES constants are the right way to keep the two surfaces in lockstep.
  • Correct ordering & scope. Redaction runs before status flips, only in the success branch, and only on the approval prefixes — rejected/no-action/error results keep their plan text for revision. I confirmed no other consumer reads functionCall.args.plan back out of history (the args.plan hits under packages/cli/.../review/ are an unrelated --plan CLI flag).
  • Resume actually works for the common paths. --resume/--continue reuse the original sessionId (packages/cli/src/config/config.ts:1901,1914), so getPlanFilePath() resolves to the same file the original session wrote — the load-side redaction fires.

Issues & suggestions

1. (Medium) Prefix drift is unguarded at the unit level. The redaction keys off llmContent.startsWith('User approved.' | 'Leader approved.'), but the real return strings in exitPlanMode.ts ('User approved. You can now start coding…', `Leader approved.${feedback} …`) are hardcoded separately from PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES. Every unit test (coreToolScheduler.test.ts, geminiChat.test.ts) uses a MockTool with strings that duplicate the constant, and there is no test that runs the actual ExitPlanModeTool and asserts its output startsWith a prefix. So if someone rewords or localizes the approval message, redaction silently stops and the whole leak fix regresses with a green suite (only the PTY E2E would catch it). Recommend either deriving the return strings from the constant, or adding a cheap drift-guard test asserting the real tool's approval llmContent matches a prefix.

2. (Low / docs) PR description contradicts the code. "Risk & Scope" says the recording/--resume path "is left as a follow-up," but the PR does implement load-side redaction (constructor + setHistory) with dedicated tests titled "load-side, #6237". The body reads as stale — please update it so reviewers don't under-count the change. The accurate residual gap is narrower: (a) the on-disk JSONL still stores the full plan (a storage detail, not a model-facing leak), and (b) see #3.

3. (Low) --fork-session and cleaned-up plan files don't redact. --fork-session assigns a fresh randomUUID() (config.ts:1925,1934) and the fork does not copy the plan .md, so getPlanFilePath() points at a nonexistent file → load-side no-ops → the fork re-feeds the full plan. Same if the plan file was ever pruned. This fails safe (no regression vs. today) but is worth a one-line note in the code/PR as a known edge rather than leaving it implicit.

4. (Low / perf) Extra sync I/O per setHistory. On any plan-bearing session, every setHistory (compression, /clear, resume reload) does a synchronous fs.readFileSync plus a full history.map. Bounded and idempotent, but compression on long plan-bearing sessions now pays a sync read each time. Probably negligible next to compression cost; flagging for awareness.

5. (Nit) Duplicated canonicalization. canonicalPlanToolName mirrors the scheduler's canonicalToolName; since exit_plan_mode has no entry in ToolNamesMigration, the canonicalization is effectively a no-op today and purely future-proofing. Consider a single shared pure helper in tool-names.ts so the two copies can't drift, or a cross-reference comment.

Risk assessment

Low overall. The never-lie gate makes every failure mode a safe no-op (leak persists = pre-PR behavior, never a wrong pointer). The one path to a silent regression of the fix itself is #1 (prefix drift), which is why I'd prioritize a guard test there. Test coverage is otherwise strong on the redaction mechanics (matching/non-matching callId, missing string arg, stale-file gate, approved-vs-rejected, load-side wiring via constructor and setHistory).

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Review

Verified locally against branch head 01c68e5c0: the implementation does what the PR claims, the tests genuinely pin the new behavior, and the design is fail-safe in every degraded case I could construct. One mechanical blocker: the branch now conflicts with main.

What I verified (fresh worktree, hardlinked node_modules)

  • Tests: coreToolScheduler.test.ts (284) + geminiChat.test.ts (248) + exitPlanMode.test.ts (28) → 560/560 pass.
  • Counterfactual A/B: reverted only the three source files (coreToolScheduler.ts, geminiChat.ts, exitPlanMode.ts) to merge-base 7b1714489 while keeping the PR's tests. The two redaction tests fail on unpatched source; the two guardrail tests (rejected plan, missing plan file) still pass on both sides — exactly the right shape for regression + guardrail coverage.
  • Typecheck: tsc --noEmit on packages/core is clean. (Worth stating explicitly since PR CI doesn't gate on tsc.)
  • Design claims spot-checked against source:
    • Config.savePlan writes the plan text verbatim (tmp file + atomic rename), and the approval snapshot's plan is exactly this.params.plan — so the byte-for-byte equality gate between on-disk file and in-history arg holds in the real flow.
    • No false positives from prefix matching: the only non-approval return that starts with "Leader approved" is 'Leader approved the plan, but failed to exit plan mode…', which does not match the 'Leader approved.' prefix (no period).
    • The <system-reminder> blocks the scheduler appends after tool results go to a separate content variable; the redaction checks toolResult.llmContent unmodified — and prefix (rather than equality) matching keeps this robust regardless.
    • The partial-push markers (pendingPartialAssistantTurnIndex, rollbackRecoveryAttempt) compare by index and role, so the immutable same-index entry replacement cannot desync them — the doc comment's claim is accurate.
    • canonicalPlanToolName is a faithful mirror of the scheduler's exported canonicalToolName (same one-line ToolNamesMigration lookup).

Findings

1. Merge conflict with main — action needed. GitHub reports CONFLICTING; a dry-run merge shows the only conflicted file is packages/core/src/core/coreToolScheduler.test.ts (main has moved around the mock-config area since). Trivial rebase, but it blocks merging as-is.

2. Prefix lockstep is comment-enforced only — nice-to-have. PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES is exported from exitPlanMode.ts, but the two success returns remain independent string literals. If they ever drift, the failure mode is a silent no-op — the redaction just stops firing, and by design nothing surfaces. The scheduler tests use MockTool with hand-written llmContent strings, so they wouldn't catch the drift either. Consider deriving the return strings from the constant (e.g. `${PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES[0]} You can now start coding…`) or adding one unit test asserting the real execute() approval llmContent starts with one of the exported prefixes.

3. Degraded cases all fail toward the status quo — no action. For the record, the cases where the plan text silently stays in context: multiple approved plans in one session (the file holds only the last; earlier calls rehydrate unredacted — documented in the code), a plan-required teammate's own history (documented in the PR), recordings whose functionResponse parts lack ids, and any hypothetical future param coercion that made args.plan differ from the saved snapshot. Every one degrades to pre-PR behavior rather than toward a lying pointer — the "never claim a save that didn't happen" invariant is consistently enforced on both the write and load sides. Good property.

4. Sync readFileSync on wholesale history loads — note only. The constructor and every setHistory (including compression) re-read the plan file whenever any exit_plan_mode call is present in history. It's one small file on rare events, so this is fine — flagging it only so it stays a conscious choice.

CI note

The red review-pr check is the bot-review job that died on model quota (see thread), not a code failure. Ubuntu Test is green.

Verdict: LGTM after the rebase onto main. The core mechanism (targeted immutable rewrite by callId, gated on approval prefixes and on-disk plan equality, with a load-side counterpart for --resume) checks out against the actual code paths, and the test suite would catch a regression in either direction.

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

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 for PR #7197

Root cause

The base branch (main) added two new optional parameters —
truncateToolOutputThreshold and truncateToolOutputLines — to the
createSchedulerForLegacyToolTests options type in
packages/core/src/core/coreToolScheduler.test.ts, wiring them through to
the mock Config. This PR concurrently added getGeminiClient and
getPlanFilePath to the same options type and the same mock-config block.
Both edits landed in the same 4-line window (the tail of the options
interface), producing a single textual conflict.

Textual or semantic

Textual only. The two sides added independent, orthogonal fields to the
same options object. No logic overlapped; neither side referenced the
other's fields. The resolution keeps all four new fields:

toolOutputBatchBudget?: number;
getGeminiClient?: () => unknown;
getPlanFilePath?: () => string;
truncateToolOutputThreshold?: number;
truncateToolOutputLines?: number;

The mock-config body below the conflict auto-merged cleanly: it already
references options.truncateToolOutputThreshold,
options.truncateToolOutputLines, options.getGeminiClient, and
options.getPlanFilePath on their respective lines, so no further edit
was needed there.

What is load-bearing

  • The ordering of the four fields in the options type does not matter
    (TypeScript interface, not a tuple), but all four must remain present
    and optional — downstream test cases in this same file exercise each
    individually (the PR's plan-redaction tests use getGeminiClient /
    getPlanFilePath; main's batch-boundary / truncation tests use
    truncateToolOutputThreshold / truncateToolOutputLines).
  • setApprovalMode was also introduced by main adjacent to this region
    and was auto-merged into both the options type and the mock config.
    It must stay wired through or the new setApprovalMode test cases
    that main added will fail.

What I could not verify

  • No build or tests are run in this flow. The PR's own CI will cover
    correctness.
  • Only the one conflicted file was edited (coreToolScheduler.test.ts).
    The auto-merged siblings — coreToolScheduler.ts, geminiChat.ts,
    geminiChat.test.ts — were inspected for stray conflict markers and
    confirmed to still contain the PR's redaction logic intact, but were
    not otherwise modified.
  • Main introduced substantial new test content in the test file (Plan
    shell describe blocks, validation-retry-loop tests, telemetry-span
    tests). These arrived via clean auto-merge and were not in scope for
    this resolution; any semantic drift they introduce against the PR's
    scheduler changes is for the PR's CI to surface.
中文说明

PR #7197 合并冲突解决

根因

基线分支 (main) 在 packages/core/src/core/coreToolScheduler.test.ts
createSchedulerForLegacyToolTests 选项类型上新增了两个可选参数
truncateToolOutputThresholdtruncateToolOutputLines,并将它们连接到
mock Config。本 PR 同时向同一选项类型和同一 mock 配置块新增了
getGeminiClientgetPlanFilePath。两处修改落在同一 4 行窗口(选项接
口尾部),产生了一处文本冲突。

文本冲突还是语义冲突

仅为文本冲突。 两边在同一选项对象上独立地增加了正交的字段,没有逻辑
重叠。解决方案保留全部四个新字段。

关键约束

  • 四个字段在选项类型中的顺序无关紧要(TypeScript 接口而非元组),但必须
    全部保留且为可选。下游测试分别使用这些字段。
  • setApprovalMode 也由 main 在该区域附近引入,已被自动合并到选项类型和
    mock 配置中,必须保持连接。

无法验证的内容

  • 此流程不运行构建或测试,由 PR 自身的 CI 覆盖正确性。
  • 仅修改了冲突文件 (coreToolScheduler.test.ts)。自动合并的兄弟文件已检
    查无残留冲突标记,PR 的红action逻辑完整保留,但未做其他修改。

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Review — reviewed at 982e24d67

Overview. After an approved exit_plan_mode, the scheduler swaps the plan argument still sitting in the model turn's functionCall for a pointer to the saved plan file, and a load-side counterpart re-applies the same rewrite on every wholesale history load (constructor / setHistory) so --resume doesn't re-feed the blob from JSONL. The mechanism is sound and the defenses added across the earlier rounds are the right ones: keying off the approval llmContent prefixes rather than tool success (rejected plans keep their text), the byte-for-byte expectedPlan gate against the on-disk file (never point at a save that failed), immutable same-index replacement (partial-push markers compare by index and role), and canonicalized tool-name matching on both surfaces.

What I ran. Re-ran the three touched suites at this commit — coreToolScheduler.test.ts + geminiChat.test.ts + exitPlanMode.test.ts, 580 passed. Then an 8-mutation matrix against the new logic: 6 killed, 2 survived (details in the Suggestions below). Ubuntu CI is green on this head.

This PR is ~5 review rounds in, so per AGENTS.md my recommendation is to land only #2 (a two-line log) now and file the rest as follow-ups — none of them are correctness breaks in the reviewed path.


Findings

1. [Medium] Both surfaces silently no-op when the provider emits an id-less functionCall.
Turn.handlePendingFunctionCall synthesizes the scheduler's call id when the provider omits one (packages/core/src/core/turn.ts:637-639, fnCall.id ?? \${fnCall.name}-${Date.now()}-...`), and that synthetic id is never written back into the history part. Both new surfaces key on the id: redactApprovedPlanFromHistorymatchespart.functionCall?.id === callId (geminiChat.ts:3662), and the load-side pass bails on !fr?.id/!fc?.id (geminiChat.ts:215, :241). So on any path whose model turn carries an id-less exit_plan_modecall, the redaction does nothing — that state is real enough thatscanModelTurn guards it the same way (if (fc?.id)`).

I confirmed this against this commit with a throwaway probe: for a history whose functionCall has no id and whose paired functionResponse carries the synthesized id plus User approved., the write side returns false and the load side returns null, plan text untouched.

Not a break for the primary qwen / OpenAI-compatible path — openaiContentGenerator/converter.ts:1689 backfills an id on the streaming path and the non-streaming path uses toolCall.id — but the bound is worth either widening (fall back to matching on tool name + position within the model turn) or stating in the doc comment next to the PR's other stated bounds.

2. [Medium] Every silent skip is invisible — the boolean return is discarded.
coreToolScheduler.ts:4657 calls .redactApprovedPlanFromHistory(...) and drops the result; the catch only fires when readFileSync throws. A no-match call id (finding #1), a non-string plan, and an expectedPlan mismatch all return false with no log at all — which is exactly the set of cases an operator triaging a "the plan is still leaking" report needs to distinguish from "the redaction ran". The load side discards redactApprovedPlansInHistory's null the same way, and its debug log only covers the fs failure. One if (!redacted) debugLogger.debug(...) on each side closes it, and it's the cheapest fix on this list.

3. [Medium] The pointer text names a file the model can't read without a permission prompt.
The default plans dir is ~/.qwen/plans (storage.ts:295), outside the workspace. ReadFileTool.getDefaultPermission allows only getProjectTempDir(), <projectDir>/subagents, Storage.getGlobalTempDir(), the user skills dirs and the user extensions dir (read-file.ts:130-148) — the plans dir is in none of them, and isPathWithinWorkspace is false. So the pointer's own instruction ("read that file if you need to consult it again") lands on an ask confirmation, popped at exactly the moment the user just approved the plan and told the agent to start coding; in non-interactive / ACP flows an ask can resolve to a denial instead.

This matters more than a normal UX nit because nothing re-injects the plan after approval — Config.loadPlan() has no production callers, so the file pointer is the only recovery route the model has. Adding config.getPlansDir() to that allowedRoots array (and to AcpAgent.buildAcpLocalReadRoots, per the SYNC comment above it) would make the escape hatch usable. Follow-up material, but it should land before this trade is considered complete.


Suggestions

4. The never-lie gate is unpinned at the scheduler layer. Dropping the third argument (savedPlan) from the scheduler's call leaves all 580 tests green — one of the two mutation survivors. The unit test added in 566dba220 pins redactApprovedPlanFromHistory's expectedPlan branch, but nothing pins that the scheduler passes it; the existing "save failed" scheduler test points at a nonexistent path, so it exercises the readFileSync throw, not the gate. The realistic bug shape is a scheduler test where the plan file exists but holds different content — savePlan fails for plan B while the file still holds plan A from an earlier approval, which is precisely the case the gate was added for.

5. The "must run BEFORE setStatusInternal" invariant is unpinned. The other survivor: moving the whole redaction block below this.setStatusInternal(callId, 'success', successResponse) also leaves 580/580 green. The comment declares a load-bearing ordering constraint (synchronous continuation submit must see sanitized history) that no test defends, so a future refactor can reorder it silently.

6. New import cycle. geminiChat.ts:51 adds geminiChat → tools/exitPlanMode → config/config (value import of ApprovalMode) → core/client → geminiChat. Before this PR geminiChat's tools/* imports were all leaves (tools.ts, tool-names.ts, syntheticOutput.ts — none reach config). It's ESM-safe because the constant is only read inside function bodies, but it's the same coupling the diff deliberately avoids two lines above, where canonicalPlanToolName is mirrored locally "to avoid a geminiChat -> coreToolScheduler import cycle". Moving PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES into tool-names.ts — a true leaf already imported by both files — keeps the single-source-of-truth property without the cycle.

7. Both new read sites bypass Config.loadPlan(). loadPlan() (config.ts:5438) already does getPlanFilePath() + assertPlansDirWithinTargetDir() + assertPlanFilePathWithinTargetDir() + ENOENT → undefined. The two new sites re-implement the read with a raw fs.readFileSync and skip the containment asserts. Not exploitable as written — the content is only compared for equality and never enters context — but it splits ownership of plan-file access across three places.


Nits

  • The test named redacts the plan for a leader-approved (teammate) exit_plan_mode wires getGeminiClient to the chat holding the call, but InProcessBackend overrides getPlanFilePath / getWorkingDir / … and not getGeminiClient, so a real plan-required teammate's scheduler reaches the main session's client and the rewrite no-ops on the unmatched call id — which the PR body states correctly. The test is a valid Leader approved. prefix guard; the name reads as end-to-end teammate coverage it doesn't have.
  • The two scheduler tests unlinkSync their fixture after the assertions, so a failing expectation leaks a file in os.tmpdir(). An afterEach cleanup keeps that honest.
  • Keying on the generic prefix 'User approved.' works and the coupling is documented on both sides, but a structured signal on the tool result (e.g. a planApproved flag) would be sturdier than string matching for a rewrite this consequential.
中文说明

总览(审阅提交 982e24d67

批准 exit_plan_mode 后,调度器把仍留在模型轮次 functionCall 里的 plan 参数换成指向已保存 plan 文件的短引用;读侧在每次整体加载历史(构造函数 / setHistory)时再跑一遍同样的改写,使 --resume 不会从 JSONL 把大文本重新喂回去。机制本身站得住,前几轮补上的防护也都是对的:以批准的 llmContent 前缀(而非"工具成功")为键——被拒的 plan 保留原文;与磁盘文件逐字节比对的 expectedPlan 闸门——绝不指向一次失败的保存;同索引不可变替换——partial-push 标记按索引与角色比对,不会失配;两侧都做工具名归一化。

我跑了什么:在该提交上重跑三个改动的测试文件(coreToolScheduler / geminiChat / exitPlanMode),580 全过;随后对新逻辑做了 8 个变异,杀死 6 个、存活 2 个(见"建议"第 4、5 条)。Ubuntu CI 在该 head 上是绿的。

本 PR 已经走了约 5 轮评审,按 AGENTS.md,我的建议是本轮只落第 2 条(两行日志),其余开 follow-up——它们都不是被审路径上的正确性断裂。

问题

1.〔中〕provider 不给 functionCall.id 时,两侧都会静默失效。
turn.ts:637-639fnCall.id 缺失时合成一个 ${name}-${Date.now()}-... 作为调度器的 callId,而这个合成 id 从不写回历史里的 part。两个新面都以 id 为键:写侧 part.functionCall?.id === callIdgeminiChat.ts:3662),读侧遇到 !fr?.id / !fc?.id 直接跳过(geminiChat.ts:215:241)。因此只要模型轮次里的 exit_plan_mode 调用没有 id,改写就什么也不做——这个状态是真实存在的,scanModelTurnif (fc?.id) 做了同样的防御。

我在该提交上用一次性探针确认:历史中 functionCall 无 id、配对的 functionResponse 带合成 id 与 User approved. 时,写侧返回 false、读侧返回 null,plan 原文纹丝不动。对主力 qwen / OpenAI 兼容链路不构成断裂(converter.ts:1689 流式路径会补 id,非流式直接用 toolCall.id),但这个边界要么放宽匹配(回退到工具名 + 轮内位置),要么像 PR 里其它已声明的边界一样写进文档注释。

2.〔中〕所有静默跳过都不可见——返回值被丢弃。
coreToolScheduler.ts:4657 调用 .redactApprovedPlanFromHistory(...) 后丢弃返回值,catch 只在 readFileSync 抛错时触发。callId 不匹配(第 1 条)、plan 不是字符串、expectedPlan 不一致,三种情况都返回 false 且毫无日志——而这恰恰是排查"plan 还在泄漏"时最需要与"改写已执行"区分开的那组情况。读侧同样丢弃 null,其 debug 日志只覆盖文件读失败。两侧各加一句 if (!redacted) debugLogger.debug(...) 即可,也是本列表里最便宜的修复。

3.〔中〕短引用指向的文件,模型读它要先弹权限确认。
默认 plans 目录是 ~/.qwen/plansstorage.ts:295),在工作区之外。ReadFileTool.getDefaultPermission 只放行 getProjectTempDir()<projectDir>/subagentsStorage.getGlobalTempDir()、用户 skills 目录与用户 extensions 目录(read-file.ts:130-148),plans 目录一个都不沾,isPathWithinWorkspace 也为假。于是短引用自带的那句"需要时可重读该文件"会撞上 ask 确认框——恰好发生在用户刚批准 plan、让 agent 开始写代码的时刻;在非交互 / ACP 流程里 ask 还可能直接解析为拒绝。

这比一般的体验瑕疵更重要,因为批准之后没有任何地方会把 plan 重新注入——Config.loadPlan() 在生产代码里没有调用点,文件指针是模型唯一的找回路径。把 config.getPlansDir() 加进那个 allowedRoots(以及其上方 SYNC 注释提到的 AcpAgent.buildAcpLocalReadRoots)就能让这条退路真正可用。可作为 follow-up,但应在认定这个取舍"已完成"之前落地。

建议

4. never-lie 闸门在调度器层没有被测试钉住。 把调度器调用里的第三个参数(savedPlan)删掉,580 个测试仍然全绿——这是两个存活变异之一。566dba220 新增的单测钉住了 redactApprovedPlanFromHistoryexpectedPlan 分支,但没有任何测试钉住"调度器确实传了它";现有的"保存失败"调度器测试指向一个不存在的路径,走的是 readFileSync 抛错,而非闸门本身。真正该覆盖的形状是:plan 文件存在但内容不同——plan B 保存失败、文件里还是上一次批准的 plan A,正是这个闸门被加进来要防的场景。

5.「必须在 setStatusInternal 之前执行」这个不变量没有被钉住。 另一个存活变异:把整个改写块移到 this.setStatusInternal(callId, 'success', successResponse) 之下,同样 580/580 全绿。注释声明了一个承重的顺序约束(同步提交的 continuation 必须看到净化后的历史),却没有测试守它,日后重构可以悄无声息地把顺序换掉。

6. 新增了一个导入环。 geminiChat.ts:51 引入 geminiChat → tools/exitPlanMode → config/config(值导入 ApprovalMode→ core/client → geminiChat。本 PR 之前 geminiChat 的 tools/* 导入全是叶子(tools.tstool-names.tssyntheticOutput.ts 都不触及 config)。因为常量只在函数体里读取,ESM 层面是安全的,但这正是同一段代码上方两行刻意规避的那类耦合——canonicalPlanToolName 之所以本地镜像,注释写的就是"避免 geminiChat -> coreToolScheduler 导入环"。把 PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES 挪进 tool-names.ts(真叶子,且两个文件都已导入)既保住单一事实源,又没有环。

7. 两个新读取点都绕过了 Config.loadPlan() loadPlan()config.ts:5438)已经做了 getPlanFilePath() + assertPlansDirWithinTargetDir() + assertPlanFilePathWithinTargetDir() + ENOENT → undefined。新代码用裸 fs.readFileSync 重新实现,并跳过了包含性断言。按现状不可利用——内容只用于相等比较,从不进入上下文——但 plan 文件访问的归属被拆到了三处。

小点

  • 名为 redacts the plan for a leader-approved (teammate) exit_plan_mode 的测试把 getGeminiClient 接到了持有该调用的同一个 chat 上;而 InProcessBackend 覆写的是 getPlanFilePath / getWorkingDir 等,并未覆写 getGeminiClient,所以真实 plan-required teammate 的调度器触达的是主会话 client、改写会因 callId 不匹配而 no-op——PR 正文的描述是准确的。这个测试作为 Leader approved. 前缀守卫是有效的,但名字读起来像是端到端覆盖了 teammate 场景,实际没有。
  • 两个调度器测试在断言之后才 unlinkSync 夹具文件,断言一旦失败就会在 os.tmpdir() 里留下垃圾;加个 afterEach 更稳。
  • 'User approved.' 这样宽泛的前缀为键是可行的,两侧耦合也都写了注释;但对一次后果这么重的改写,工具结果上的结构化信号(例如 planApproved 标志)会比字符串匹配更耐久。

The write side discarded redactApprovedPlanFromHistory's boolean and the
load side discarded redactApprovedPlansInHistory's null, so a no-match
call id, a non-string plan argument, and an expectedPlan mismatch were
indistinguishable from a redaction that ran. Both sides now emit a debug
log when the rewrite leaves history unchanged (review finding QwenLM#2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zjunothing

Copy link
Copy Markdown
Collaborator Author

Thanks for the deep review and the mutation matrix — landed the recommended scope in 074cf8d: finding #2 is closed with a debug trace on both surfaces when the rewrite leaves history unchanged (write side: no-match call id / non-string plan / expectedPlan mismatch after the boolean was previously discarded; load side: null result after the hasPlanCall gate, mirroring the existing plan-file-unavailable log). 580/580 on the three touched suites, eslint/prettier clean.

Agreed on filing the rest as follow-ups; summarizing my read for the record:

中文

多谢深度 review 和变异矩阵——按建议的范围在 074cf8d 落了 #2:两侧在改写未触及历史时都补了 debug 追踪(写侧:此前被丢弃的布尔返回值对应的 call id 不匹配 / plan 非字符串 / expectedPlan 不一致;读侧:过了 hasPlanCall 门之后的 null 结果,与既有的 plan 文件不可用日志对齐)。三个套件 580/580,eslint/prettier 全绿。

其余按 follow-up 处理,同意。记录一下我的看法:#1 是确认的边界——合成 id 从不写回历史,按 id 匹配注定失效;放宽到名字+位置匹配的风险比漏掉更大(改错目标比跳过更糟),先文档化边界 + 靠新加的 #2 追踪让这种情况可见,更稳妥。#3 同意应在收尾前落地——给 ReadFileTool.getDefaultPermissionAcpAgent.buildAcpLocalReadRoots 加 plans 目录,小而可测,可以接。#4/#5 都是纯测试补充(存在但内容陈旧的 plan 文件的调度器测试;针对 setStatusInternal 的顺序探针),可合并成一个 follow-up PR。#6 把前缀常量挪进 tool-names.ts 干净且机械,可一并做。#7 收敛到 Config.loadPlan() 需要小的签名改动,单独重构。

@zjunothing

Copy link
Copy Markdown
Collaborator Author

Follow-up for finding #3 is up as #7678config.getPlansDir() added to both ReadFileTool.getDefaultPermission allowedRoots and AcpAgent.buildAcpLocalReadRoots (per the SYNC comment), with a test pinning that sibling ~/.qwen files like settings.json stay confirmation-gated. 中文:finding #3 的 follow-up 已提交为 #7678,两处镜像读根同步加入 plans 目录,并有测试钉住 settings.json 等不被放开。

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

Comment on lines +11393 to +11397
describe('redactApprovedPlanFromHistory', () => {
// After an approved exit_plan_mode the full plan text would otherwise
// stay in history as the model's own tool-call argument and get
// regurgitated into later responses (#6237). These tests pin the
// targeted history rewrite the tool scheduler performs post-approval.

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 exercises canonicalPlanToolName with a legacy tool-name alias — every test here uses name: 'exit_plan_mode' directly, so the ToolNamesMigration lookup path is unverified. — Failure scenario: if ToolNamesMigration gains an exit_plan_mode alias and canonicalPlanToolName has a bug (wrong lookup direction, or the Record<string, string> cast silently drops entries), a --resumed session with history recorded under the legacy name would silently skip redaction — plan text leaks back into the model's context despite being approved and saved. Today this cannot fire (no exit_plan_mode alias exists), but the scheduler's own tests pin the same contract with it.each(Object.entries(ToolNamesMigration)) — the pattern is established and cheap to mirror.

it('resolves legacy tool-name aliases when matching exit_plan_mode calls', () => {
  const history: Content[] = [
    {
      role: 'model',
      parts: [{
        functionCall: {
          id: 'call-a',
          name: 'exit_plan_mode_legacy',
          args: { plan: PLAN },
        },
      }],
    },
    {
      role: 'user',
      parts: [{
        functionResponse: {
          id: 'call-a',
          name: 'exit_plan_mode_legacy',
          response: { output: 'User approved. You can now start coding.' },
        },
      }],
    },
  ];
  const out = redactApprovedPlansInHistory(history, PLAN, PLAN_PATH);
  expect(out).not.toBeNull();
  expect(out![0]!.parts![0]!.functionCall!.args!['plan']).toBe(
    approvedPlanRedactionText(PLAN_PATH),
  );
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed it's a real gap, but per wenshao's round-5 guidance this PR lands only the two-line logging change and defers the remaining test pins to a follow-up — I'll bundle a legacy-alias case for canonicalPlanToolName (both surfaces) into that follow-up test PR alongside the scheduler savedPlan gate pin and the setStatusInternal ordering probe.

中文

确实是缺口,但按 wenshao 第 5 轮的意见,本 PR 只落两行日志改动、其余测试钉全部推 follow-up——canonicalPlanToolName 的 legacy 别名用例(两个面)会和调度器 savedPlan 闸门钉、setStatusInternal 顺序探针一起放进那个 follow-up 测试 PR。

pull Bot pushed a commit to Little-Star888/qwen-code that referenced this pull request Jul 25, 2026
…pt (QwenLM#7678)

The default plans dir (~/.qwen/plans) sits outside the workspace and was
in none of ReadFileTool's permission-free roots, so reading a saved plan
back landed on an ask confirmation — popped at exactly the moment the
user approved the plan and told the agent to start coding, and resolvable
as a denial in non-interactive/ACP flows. With the approved-plan pointer
(QwenLM#7197) the saved file is the model's only recovery route for the plan
text, so the read must not stall on a prompt.

Adds config.getPlansDir() to ReadFileTool.getDefaultPermission's
allowedRoots and to AcpAgent.buildAcpLocalReadRoots (per the SYNC
comment). The dir holds only session plan files; sibling ~/.qwen files
such as settings.json stay confirmation-gated, pinned by a new test.

Refs QwenLM#6237

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Review — fix(core): redact the plan argument from history after an approved exit_plan_mode

Overall: the direction is right and the implementation is careful. The "never-lie" gate (only redact when the on-disk plan matches the in-history plan byte-for-byte) is the correct invariant, rejected/no-action results are properly preserved, and the rewrite is placed before setStatusInternal('success') so the synchronously-submitted continuation already sees sanitized history. Verified on the PR branch in an isolated worktree:

  • packages/core: tsc --noEmit clean; exitPlanMode.test.ts + geminiChat.test.ts 277/277 pass; coreToolScheduler.test.ts -t plan 21/21 pass.
  • Mutation checks — the new tests are load-bearing, not decorative: dropping this.redactApprovedPlansFromLoadedHistory() from the GeminiChat constructor fails 1 test; short-circuiting the scheduler's canonicalName === EXIT_PLAN_MODE block fails 2.
  • Prefix constant is safe against the near-miss: errorResult('Leader approved the plan, but failed to exit plan mode: …') correctly does not startsWith('Leader approved.').
  • Partial-push markers: redactApprovedPlanFromHistory replaces at the same index with the same role, and popPartialIfPushed compares index+role only — the "any single-field reset is a bug" contract is not violated. Load-side runs after clearPendingPartialState() in setHistory. Good.

Blocker

1. Merge conflict with main. mergeable: CONFLICTING. git merge-tree origin/main <head> shows a single content conflict in packages/core/src/core/coreToolScheduler.test.ts (both branches appended it() blocks at the same anchor) — mechanical, but must be resolved before merge.

Medium

2. redacts the plan for a leader-approved (teammate) exit_plan_mode asserts wiring that does not exist in production.

In the teammate path, the scheduler's this.config is the subagent override built by createApprovalModeConfigOverride (InProcessBackend.ts:593), which is Object.create(base) and overrides getPlanFilePath but not getGeminiClient. So this.config.getGeminiClient() resolves through the prototype to the main session's client — not the teammate's own chat (created at agent-core.ts:466 with runtimeContext). The teammate's callId can never match there, so the redaction is a guaranteed no-op on that path.

The test hides this by injecting getGeminiClient: () => ({ getChat: () => chat }) returning the very chat that holds the matching callId. The PR body does disclose the limitation under "Not validated / out of scope", but the test name directly contradicts it — a future reader will believe the teammate path is covered. Either rename it to what it actually covers (the 'Leader approved.' prefix branch of PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES), or fix the path for real by resolving the teammate's own chat.

3. New runtime import cycle: geminiChat.tstools/exitPlanMode.jsconfig/config.jscore/client.jscore/geminiChat.js.

geminiChat.ts previously imported config.js type-only (line 40) and only leaf tool modules at runtime (tools.js, tool-names.js, syntheticOutput.js). exitPlanMode.ts value-imports ApprovalMode from config/config.js, which imports GeminiClient (config.ts:38), which imports geminiChat.js. It happens to work today because the constant is only dereferenced at call time, but this is exactly the hazard the PR's own canonicalPlanToolName local mirror was added to avoid ("kept here to avoid a geminiChat -> coreToolScheduler import cycle") — so the reasoning is applied inconsistently.

Suggested fix: move PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES into a leaf module (tools/tool-names.ts, or a small tools/planConstants.ts) that exitPlanMode.ts, coreToolScheduler.ts and geminiChat.ts all import. That preserves the anti-drift property without the cycle, and also removes the need for the canonicalPlanToolName duplicate if the helper moves alongside it.

4. The pointer text tells the model to read a file that read_file will prompt on.

approvedPlanRedactionText says "read that file if you need to consult it again", but the default plans dir is ~/.qwen/plans/ (Storage.getPlansDirgetGlobalQwenDir()/plans). ReadFileTool.getDefaultPermission (read-file.ts:120-152) allows only workspace paths, project/global temp dirs, user-skills, extensions and auto-memory roots — ~/.qwen/plans matches none, so it returns 'ask'. Following the pointer therefore raises a confirmation dialog for a file the tool itself just wrote, in the default (non-YOLO) approval mode.

Since the whole trade of this PR is "you lose the in-context plan but you can re-read it", that re-read should be frictionless. Consider adding this.config.getPlansDir() to allowedRoots in getDefaultPermission.

Low

5. The one case where the fix silently doesn't apply is also the one case not traced.

const redacted = this.config.getGeminiClient?.()?.getChat().redactApprovedPlanFromHistory(...)
if (redacted === false) { debugLogger.debug(...) }

When getGeminiClient() returns null/undefined (which Config legitimately can before client init), redacted is undefined, not false — so the "left history unchanged" trace never fires for the no-client case. if (redacted !== true) covers both.

6. setHistory() now does a sync readFileSync + full history scan on every call.

setHistory is not only a "wholesale load" entry point — GeminiClient.microcompactHistoryBeforeSend calls it (client.ts:1869) whenever microcompaction changes anything, i.e. potentially before every send; tryCompress (geminiChat.ts:1735) and compressFast (:1834) also route through it. In a plan-mode session (hasPlanCall true) that is a blocking read of the plan file plus an O(parts) scan per turn, and after the in-session write-side redaction the load pass can never match anyway (the arg is already the pointer), so it only emits the "left history unchanged" debug line. The method doc ("Every wholesale history load re-applies the redaction") understates this. Suggest caching by (path, mtimeMs), or invoking the pass only from the constructor + genuine reload sites rather than from setHistory unconditionally.

7. Silently no-ops for providers that omit functionCall.id.

Both surfaces key on functionCall.id / functionResponse.id. Turn.handlePendingFunctionCall (turn.ts:638) synthesizes ${name}-${Date.now()}-${rand} when the provider omits fnCall.id, but the history part keeps the id-less functionCall, so part.functionCall?.id === callId never matches and the leak persists with no user-visible signal. The equality gate against the on-disk plan already makes a name+plan-text fallback safe when id is absent — worth adding, or at least documenting.

8. --fork-session is a second unlisted gap in the resume story.

packages/cli/src/config/config.ts:1971-1982 mints a new sessionId for a fork, so getPlanFilePath() points at a file that was never written → ENOENT → load-side redaction no-ops and the forked session re-feeds the full plan text. Worth listing next to the existing --resume caveat. (--resume/--continue themselves do reuse the sessionId — lines 1949/1962 — so the main claim holds.)

9. Nit: the PR body carries a 🤖 Generated with [Claude Code] trailer; the repo's other PRs don't.

Things I checked and found fine

  • savePlanBestEffort(snapshot.plan) with snapshot.plan = this.params.plan (exitPlanMode.ts:147), and savePlan writes the string verbatim — so the equality gate holds in the normal case and correctly blocks when a save failed and the file still holds a previous plan.
  • convertToFunctionResponsecreateFunctionResponsePart always produces response: { output }, so the load-side response.output shape assumption is correct.
  • returnDisplay.plan untouched → TUI still renders the approved plan.
  • The immutable rewrite doesn't break imagePayloadStore / curated-history consumers.
  • No plan-file GC exists in the repo, so the resume-side file lookup won't be undermined by cleanup.
中文说明

总体:方向正确、实现谨慎。"绝不撒谎"门(仅当磁盘上的 plan 与历史中的 plan 逐字节相等时才改写)是正确的不变式;被拒绝/未生效的结果被正确保留;改写放在 setStatusInternal('success') 之前,同步提交的 continuation 已看到净化后的历史。已在隔离 worktree 中于 PR 分支本地验证:packages/core typecheck 干净;exitPlanMode.test.ts + geminiChat.test.ts 277/277 通过;coreToolScheduler.test.ts -t plan 21/21 通过。变异测试确认新增测试是有效的(去掉构造函数中的调用会挂 1 个;短路 scheduler 判断会挂 2 个)。

阻塞项

  1. 与 main 冲突mergeable: CONFLICTING):仅 coreToolScheduler.test.ts 一处内容冲突(两侧在同一锚点追加 it()),机械冲突,但需先解决。

中等

  1. leader-approved (teammate) 测试断言的接线在生产中不存在。 teammate 路径下 scheduler 的 this.configcreateApprovalModeConfigOverrideObject.create(base))产生的覆盖 config,它覆盖了 getPlanFilePath没有覆盖 getGeminiClient,因此拿到的是主会话的 client,而非 teammate 自己的 chat(agent-core.ts:466);callId 永远不匹配,该路径必然 no-op。测试通过注入 getGeminiClient: () => ({getChat: () => chat}) 掩盖了这点。PR 描述虽已披露该限制,但测试名与之矛盾。建议改名为覆盖 'Leader approved.' 前缀分支,或真正修复该路径。

  2. 新增运行时循环依赖geminiChat.tstools/exitPlanMode.jsconfig/config.js(值导入 ApprovalMode)→ core/client.jscore/geminiChat.js。geminiChat 此前只以 type-only 方式引用 config(第 40 行),运行时只引叶子模块。这与本 PR 自己为"避免 geminiChat -> coreToolScheduler 循环"而新增 canonicalPlanToolName 本地镜像的理由自相矛盾。建议把 PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES 移到叶子模块(tool-names.ts 或新建 planConstants.ts),三方共同引用。

  3. 指针文本让模型去读一个 read_file 会弹确认的文件。 默认 plans 目录是 ~/.qwen/plans/,而 ReadFileTool.getDefaultPermissionread-file.ts:120-152)的白名单只含 workspace / project+global temp / user-skills / extensions / auto-memory,因此返回 'ask'。本 PR 的核心取舍就是"上下文里没有了,但可以重读",这个重读应当无摩擦。建议把 getPlansDir() 加入 allowedRoots

较低

  1. 唯一"静默不生效"的场景恰恰没有 trace:getGeminiClient() 返回 null 时 redactedundefined 而非 falseif (redacted === false) 不触发。改为 if (redacted !== true)
  2. setHistory() 现在每次调用都做同步 readFileSync + 全量历史扫描。setHistory 并非只在"整体加载"时调用——microcompactHistoryBeforeSendclient.ts:1869)几乎每轮发送前都可能调用,tryCompress / compressFast 也走这里。建议按 (path, mtimeMs) 缓存,或只在构造函数 + 真正的 reload 入口调用。
  3. 两侧都以 functionCall.id 为键;turn.ts:638 在 provider 缺 id 时合成 callId,但历史里的 functionCall 仍无 id,导致对这类 provider 静默失效。等值门已使 name+plan 文本回退是安全的。
  4. --fork-sessionconfig.ts:1971-1982)会生成 sessionId,plan 文件路径指向从未写过的文件 → load-side 改写 no-op,fork 出的会话会重新带回完整 plan 文本。建议与 --resume 的已知限制并列说明。(--resume/--continue 本身复用 sessionId,主张成立。)
  5. Nit:PR 描述带有 🤖 Generated with [Claude Code] 落款。

已核对且无问题snapshot.plan = this.params.plansavePlan 原样写盘,等值门在正常情况成立、在保存失败时正确阻断;convertToFunctionResponse 确实产出 response: { output }returnDisplay.plan 未动,TUI 仍渲染完整 plan;不可变改写不破坏 partial-push 标记(按 index+role 比对)与 imagePayloadStore;仓库中不存在 plan 文件的 GC。

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Merge resolution for PR #7197

Root cause

PR #7671 (landed on main) added two new tests for exit_plan_mode guidance-error paths to coreToolScheduler.test.ts — one for PM ask rules hitting exit_plan_mode outside plan mode, and one for the PLAN→non-PLAN timing boundary. PR #7197 added four plan-redaction tests and a createChatWithPlanCall helper at the same insertion point (after the hideAlwaysAllow: true test). Both branches inserted new it(...) blocks at the identical location, producing three interleaved conflict regions.

Textual or semantic

Textual only. The two sides added independent, self-contained tests at the same insertion point. Neither side modified the same logic — they were adjacent insertions that git could not auto-merge because the shared surrounding structure (createSchedulerForLegacyToolTests({...}), scheduler.schedule([{...}])) appeared identical on both sides, causing the conflicts to interleave across three marker pairs.

Resolution: kept all six tests (four from HEAD, two from origin/main) in a single block, HEAD's tests first then origin/main's.

What is load-bearing

  • The createChatWithPlanCall helper from HEAD must be declared before any of the plan-redaction tests that use it.
  • origin/main's two Plan mode exit: model not notified on manual mode switch + unhelpful deny error #7671 tests are self-contained (they create their own ExitPlanModeTool instances and createSchedulerForLegacyToolTests calls) and have no dependency on HEAD's helper or tests.
  • The test ordering within the merged block does not affect correctness — each test has its own vi.fn() mocks and AbortController.

What I could not verify

No build or tests were run. The auto-merged files (coreToolScheduler.ts, geminiChat.ts, geminiChat.test.ts, exitPlanMode.ts) were not edited — git resolved them cleanly. If any of those auto-merges introduced a semantic issue (e.g. an import ordering problem or a function signature mismatch from a main-branch refactor), it would only surface at build/test time.

中文说明

PR #7197 合并冲突解决

根因

PR #7671(已合入 main)在 coreToolScheduler.test.ts 的同一插入点(hideAlwaysAllow: true 测试之后)新增了两个 exit_plan_mode 引导错误路径测试。PR #7197 在同一位置新增了四个计划脱敏测试和一个 createChatWithPlanCall 辅助函数。两边都在相同位置插入新的 it(...) 代码块,导致三个交织的冲突区域。

文本冲突还是语义冲突

纯文本冲突。 两边添加的是独立、自包含的测试,只是恰好插入位置相同。共享的周边结构(createSchedulerForLegacyToolTests({...})scheduler.schedule([{...}]))在两边完全相同,导致 git 无法自动合并,三个冲突标记对交织在一起。

解决方式:保留全部六个测试(HEAD 四个 + origin/main 两个),HEAD 的在前,origin/main 的在后。

关键约束

  • HEAD 的 createChatWithPlanCall 辅助函数必须在使用它的计划脱敏测试之前声明。
  • origin/main 的两个 Plan mode exit: model not notified on manual mode switch + unhelpful deny error #7671 测试是自包含的(各自创建独立的 ExitPlanModeTool 实例和 createSchedulerForLegacyToolTests 调用),不依赖 HEAD 的辅助函数或测试。
  • 合并后的测试顺序不影响正确性——每个测试都有自己的 vi.fn() mock 和 AbortController

未能验证的内容

未执行构建或测试。自动合并的文件(coreToolScheduler.tsgeminiChat.tsgeminiChat.test.tsexitPlanMode.ts)未做编辑——git 干净地解决了它们。如果自动合并引入了语义问题(如导入顺序问题或函数签名不匹配),只能在构建/测试时发现。

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Maintainer local verification — real E2E at dda19ac2c

Re-verified the current head in an isolated worktree with its own npm ci and a full bundle build. Everything below is measured against a running CLI or a running suite, not read off the diff.

Verdict: LGTM — merge. The fix does on the wire exactly what it claims, on both the write side and the resume side, and degrades safely on every path I could push it down. One new coverage gap (§3) is worth folding into the follow-up test PR you already agreed to; it is not a merge blocker. Three of my earlier findings are withdrawn or resolved (§4).

1. What the CLI actually puts on the wire

The real built CLI, driven in a pty with --approval-mode plan, against the repo's own integration-tests/fake-openai-server.ts. The oracle is messages[].tool_calls[].function.arguments in every recorded /chat/completions request. The base arm is the same worktree with the five changed files reverted to the merge base 596abd966 and a clean rebundle — worth knowing if you reproduce this: npm run bundle leaves the previous hashed chunk in dist/chunks/, and my first base run silently exercised the PR's code because of it.

wire A/B

scenario request base 596abd966 PR dda19ac2c
approve #2 · post-approval continuation full plan ×5 ❌ pointer ✅
approve #4 · second user turn full plan ×5 ❌ pointer ✅
approve #6 · later side-query full plan ×5 ❌ pointer ✅
resume #7 · first turn after --continue full plan ×5 ❌ pointer ✅
reject #2 · next turn full plan ×5 (correct) full plan ×5 (correct)

The --continue row is the one nobody had exercised end-to-end, and it is a genuine load-side result rather than the write side leaking through: the chat-recording JSONL on disk still holds the full 611-char plan (functionCall.args.plan with the marker intact), and the relaunched process still puts only the pointer on the wire. The resume gap is really closed.

2. Degraded paths

  • Provider omits the tool_call id — redaction still fires. openaiContentGenerator/converter.ts:1706 synthesizes call_<ts>_<rand> before the functionCall reaches history, so the id-keyed lookup always has an id on this path.
  • Following the pointer is free — after approval the model called read_file on the saved plan and it executed with no confirmation prompt, in "Ask permissions" mode.
  • TUI unchangedreturnDisplay.plan still renders the full approved plan (screenshot below).
  • Cost of the load-side passreadFileSync instrumented inside the real CLI process: 1 plan-file read after approval, 2 after the next turn. ~1 small sync read per turn in a plan-bearing session.

TUI

3. Are the new tests load-bearing?

591/591 green at head (exitPlanMode 30 · coreToolScheduler 310 · geminiChat 251). Counterfactual — base source with the PR's own tests — fails 12: 2 in coreToolScheduler.test.ts, 10 in geminiChat.test.ts, while both guardrail tests pass on both sides. Correct shape.

Mutation matrix — delete one guard from the PR source, rerun the two suites:

# guard removed tests failed
M1 never-lie gate (in-history plan must equal the on-disk file) 1 pinned
M2 approval-prefix gate (only User/Leader approved.) 0 unpinned
M3 callId guard in redactApprovedPlanFromHistory 0 unpinned
M4 load-side redaction from the GeminiChat constructor 1 pinned
M5 load-side redaction from setHistory 1 pinned
M6 per-call-id approved gate on the load side 1 pinned

tests

New (Low — for the follow-up test PR, not a blocker): the approval-prefix gate is load-bearing but nothing pins it. keeps the plan argument in history when exit_plan_mode is not approved never writes a plan file, so the scheduler's readFileSync(planPath) throws and skips the redaction regardless of what the prefix check does — the test passes with the gate deleted. I ran the case it misses (plan approved once so the file is on disk, re-proposed, then rejected): green at head, and with the prefix gate removed the rejected plan is rewritten to [Plan approved and saved to …] — a pointer that claims an approval that never happened, and drops the text the model needs to revise. The probe is ~25 lines and is the same test with getPlanFilePath: () => planFile plus a matching writeFileSync.

M3 is the same shape but milder: the only negative test uses write_file, so the tool-name half of the dual guard covers for the id half. A wrong rewrite would additionally need two calls carrying identical plan text, which the equality gate makes unlikely.

Also measured: moving the redaction to after setStatusInternal('success') did not re-open the leak on request #2. The harness does detect a leak there (base does), so this is a real null, not a blind spot — the "must run BEFORE setStatusInternal" comment is a cheap safety margin rather than a proven requirement. No action; just don't bank on it as verified behaviour.

4. Corrections to my earlier rounds

Still open, all cosmetic and fine as follow-ups: #2 the leader-approved (teammate) test name — createApprovalModeConfigOverride overrides approvalMode/permissionManager/autoModeDenialState but never getGeminiClient, so the teammate path resolves the main session's chat and the redaction is a guaranteed no-op there; #5 if (redacted === false) misses the undefined no-client case (!== true); #8 --fork-session mints a fresh randomUUID() (cli/src/config/config.ts:1974), so the plan file is absent and the load-side pass no-ops — one line next to the existing --resume caveat.

Harness (reproducible)
  • Isolated worktree at dda19ac2c, own npm ci, npm run build && npm run bundle.
  • E2E: @lydell/node-pty + @xterm/headless driving dist/cli.js, isolated HOME, startFakeOpenAIServer from integration-tests/fake-openai-server.ts; the model returns one exit_plan_mode call whose plan carries a unique marker in 5 places. Scenarios: approve, reject, resume (--continue in a second process), noid (server omits the tool-call id), reread (model calls read_file on the saved plan).
  • Base arm: git checkout 596abd966 -- <the 5 files>, rm -rf dist, rebundle; verified no chunk contains Plan approved and saved to before running.
  • Plan-read counting: NODE_OPTIONS=--require shim patching fs.readFileSync before the ESM graph snapshots node:fs.
  • Import graph: module.register() resolve hook recording every edge, BFS for the shortest cycle back to the entry.
中文版本

Maintainer 本地验证 —— 针对 dda19ac2c 的真实 E2E

在隔离 worktree 中重新验证当前 head(独立 npm ci + 完整 bundle 构建)。以下结论全部来自真实运行的 CLI 或真实运行的测试,而非阅读 diff 得出。

结论:LGTM,可以合并。 修复在网络层面确实做到了它声称的事情——写侧与 resume 侧都成立;我能构造的所有降级路径都安全退化。§3 中新发现的一个覆盖缺口值得并入你已经同意的后续测试 PR,但不阻塞合并。我此前三条发现予以撤回或已被 main 解决(§4)。

1. CLI 实际发到线上的内容

用 pty 驱动真实构建产物(--approval-mode plan),对接仓库自带的 integration-tests/fake-openai-server.ts;判据是每个 /chat/completions 请求中的 messages[].tool_calls[].function.arguments。base 组为同一 worktree 把 5 个改动文件回退到 merge base 596abd966清理重打包——复现时需注意:npm run bundle 会保留 dist/chunks/ 下旧的哈希 chunk,我第一次的 base 组因此静默跑了 PR 的代码。

场景 请求 base 596abd966 PR dda19ac2c
批准 #2 · 批准后的 continuation 完整 plan ×5 ❌ 短引用 ✅
批准 #4 · 第二个用户轮次 完整 plan ×5 ❌ 短引用 ✅
批准 #6 · 后续 side-query 完整 plan ×5 ❌ 短引用 ✅
resume #7 · --continue 后首轮 完整 plan ×5 ❌ 短引用 ✅
拒绝 #2 · 下一轮 完整 plan ×5(正确) 完整 plan ×5(正确)

--continue 这一行此前没有人做过端到端验证,而且它确实是 load-side 的效果,不是写侧的顺带结果:磁盘上的 chat recording JSONL 仍保存着完整的 611 字符 plan(functionCall.args.plan 中 marker 完好),重启后的进程发出的仍只有短引用。resume 泄漏确实被关闭了。

2. 降级路径

  • provider 不返回 tool_call id —— 改写照常生效。openaiContentGenerator/converter.ts:1706functionCall 进入历史之前就合成了 call_<ts>_<rand>,因此该路径上按 id 查找总能命中。
  • 顺着指针重读没有摩擦 —— 批准后模型对保存的 plan 调用 read_file,在「Ask permissions」模式下没有弹出确认框直接执行。
  • TUI 无变化 —— returnDisplay.plan 仍完整渲染已批准的 plan。
  • load-side 开销 —— 在真实 CLI 进程内插桩 readFileSync:批准后 1 次、再一轮后 2 次,即含 plan 的会话中每轮约 1 次小文件同步读。

3. 新增测试是否「吃劲」

head 上 591/591 通过(exitPlanMode 30 · coreToolScheduler 310 · geminiChat 251)。反事实实验(base 源码 + PR 自带测试)挂 12 个:coreToolScheduler.test.ts 2 个、geminiChat.test.ts 10 个;两个护栏测试在两侧都通过——形状正确。

变异矩阵(从 PR 源码中删掉某个 guard 后重跑两套件):M1 never-lie 门挂 1、M2 批准前缀门挂 0M3 callId 门挂 0、M4 构造器 load-side 挂 1、M5 setHistory load-side 挂 1、M6 按 call-id 的批准门挂 1。

新发现(Low,建议并入后续测试 PR,不阻塞):批准前缀门在生产中吃劲,但没有任何测试钉住它。 keeps the plan argument in history when exit_plan_mode is not approved 从不写 plan 文件,于是调度器的 readFileSync(planPath) 抛错、无论前缀检查如何都会跳过改写——删掉该门这条测试照样通过。我补跑了它漏掉的场景(先批准一次使文件落盘,再次提出同一 plan,然后拒绝):head 上通过;移除前缀门后,被拒绝的 plan 被改写成 [Plan approved and saved to …]——一个声称「已批准」而实际并未批准的指针,同时丢掉了模型修订所需的正文。补钉方法就是同一测试加 getPlanFilePath: () => planFile 与配套的 writeFileSync,约 25 行。

M3 同形但更轻:唯一的否定用例用的是 write_file,双重守卫中「工具名」那一半替「call id」那一半兜了底;且等值门要求两次调用 plan 文本完全一致才可能误改。

另外测得:把改写移到 setStatusInternal('success') 之后,请求 #2 并没有重新泄漏。该 harness 确实能检出这里的泄漏(base 组就检出了),所以这是一个真实的 null 结果——「必须在 setStatusInternal 之前执行」是一层廉价的安全余量,而非已被证明的必要条件。无需改动,只是不要把它当作已验证行为。

4. 对我此前几轮的更正

仍然开着、但都属于表面问题,可作后续处理:#2 leader-approved (teammate) 测试名——createApprovalModeConfigOverride 覆盖了 approvalMode/permissionManager/autoModeDenialState,但从未覆盖 getGeminiClient,因此 teammate 路径拿到的是主会话的 chat,该路径上改写必然 no-op;#5 if (redacted === false) 漏掉了无 client 时的 undefined(应为 !== true);#8 --fork-session 会生成新的 randomUUID()cli/src/config/config.ts:1974),plan 文件不存在导致 load-side 直接 no-op——建议在既有 --resume 限制旁补一行说明。

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

Labels

category/core Core engine and logic type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plan Mode Content Leakage in Subsequent Responses

4 participants