fix(cli): hide stale sticky todos from previous turns - #7900
Conversation
PR QwenLM#7062 hid the sticky panel when streamingState is Idle, but the panel resurfaces with stale todos from a previous turn as soon as the user sends a new message (state → Responding). This is confusing — the user sees in-progress indicators for work that already finished in an earlier turn. Add a turn-boundary check in getStickyTodos: if a user message exists after the todo snapshot in history, the snapshot belongs to a previous turn and the sticky panel returns null. Fixes QwenLM#7061
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Hey @chiga0 — thanks for the well-researched fix! The root cause analysis and test coverage look solid.
One thing before we can move forward: the PR body doesn't follow the PR template. Could you restructure it using the template headings? Specifically:
- What this PR does / Why it's needed — your Summary + Root cause content maps well here
- Reviewer Test Plan — how to verify, and Before/After evidence (even a short description of the stale-panel scenario vs. the fixed behavior)
- Risk & Scope — main tradeoff, what's not validated
- Linked Issues —
Fixes #7061 - A Chinese translation in a
<details>block
The content you've written is great — it just needs to be reorganized into the template structure so reviewers can find what they need quickly. Once updated, re-trigger with @qwen-code /triage and we'll pick it right back up.
中文说明
感谢贡献!根因分析和测试覆盖都很扎实。
PR 描述需要按照 PR 模板 的格式重新组织:
- What this PR does / Why it's needed — 你的 Summary 和 Root cause 内容可以放在这里
- Reviewer Test Plan — 如何验证,以及 Before/After 证据
- Risk & Scope — 主要权衡、未验证的部分
- Linked Issues —
Fixes #7061 <details>中附中文翻译
内容写得很好,只需要按模板结构重新排列即可。更新后用 @qwen-code /triage 重新触发审查。
— Qwen Code · qwen3.8-max-preview
已知边界情况:Steer 消息
实际影响很小:
如果需要 100% 精确,可以在 AppContainer 提交消息时,仅当 |
| if (items[i].type === 'user') { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
[Critical] hasUserMessageAfter treats mid-turn steer messages as new user turns, hiding the sticky todo panel prematurely during an active turn.
Failure scenario: User asks model to work on todos → model calls todoWrite (snapshot at index N) → user sends a mid-turn steer ("also handle edge case Y") → steer pushed as { type: 'user', sentToModel: false } → model generates 2+ more items → hasUserMessageAfter finds the steer at type === 'user' → returns null. Panel disappears mid-turn even though no new turn started.
The established codebase pattern distinguishes real user turns from steers via sentToModel !== false — used by findLastUserItemIndex (historyUtils.ts:132), realUserPromptTexts (historyUtils.ts:143), isSyntheticHistoryItem (historyUtils.ts:65), and useAwaySummary.ts (lines 55, 63). This function omits that guard.
| if (items[i].type === 'user') { | |
| return true; | |
| } | |
| if (items[i].type === 'user' && (items[i] as HistoryItemUser).sentToModel !== false) { | |
| return true; | |
| } |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Thanks for the careful read — declining this one, because the suggested guard would regress the exact case this PR fixes.
Verified at the reviewed commit: local slash commands keep sentToModel: false (slashCommandProcessor.ts:857-860 initializes it false and only flips it to true when the command yields a submit_prompt; UI-only commands like /stats, /about, /exit never do), while regular user messages leave it undefined (types.ts:124-131). Steers are also false (useGeminiStream.ts:2576-2582). So sentToModel !== false cannot separate a mid-turn steer from a local slash command — and applying it would stop /stats (the command in the #7061 screenshot) from hiding the stale panel, re-breaking #7061. This lines up with @wenshao's independent E2E on 090ad18, which confirmed /stats triggers the boundary today.
The sentToModel !== false pattern elsewhere answers "did this reach the model?" (rewind/telemetry/turn rendering); hasUserMessageAfter answers "did the user start any new interaction?", which intentionally includes local slash commands — so the omission is deliberate. The steer case is real but minor and self-healing (next todo_write re-snapshots), already accepted by the author and @wenshao; a precise fix needs streaming state in this pure function, out of scope here.
To make sure the naive guard can't be applied silently, I added a regression test (hides sticky todos when a local slash command starts after the snapshot) using a faithful sentToModel: false fixture — it passes now and fails if the guard is added.
中文说明
感谢细致的审查 —— 这一条予以拒绝,因为建议的判断条件会让本 PR 修复的场景重新回归。
已在被审查的 commit 上核实:本地斜杠命令保持 sentToModel: false(slashCommandProcessor.ts:857-860 初始化为 false,仅当命令产生 submit_prompt 时才改为 true;/stats、/about、/exit 等纯 UI 命令从不触发),而普通用户消息保持 undefined(types.ts:124-131)。Steer 同样是 false(useGeminiStream.ts:2576-2582)。因此 sentToModel !== false 无法区分轮内 steer 与本地斜杠命令 —— 应用它会导致 /stats(#7061 截图中的命令)不再隐藏过期面板,重新破坏 #7061。这与 @wenshao 在 090ad18 上的独立 E2E 验证一致:他确认当前 /stats 能触发边界。
其他地方的 sentToModel !== false 模式回答的是"这条是否发给了模型?"(rewind/telemetry/turn 渲染);而 hasUserMessageAfter 回答的是"用户是否开启了任何新交互?",这有意包含本地斜杠命令 —— 所以这里的省略是刻意的。Steer 场景确实存在但影响很小且可自愈(下一次 todo_write 重建快照),作者与 @wenshao 均已接受;精确修复需要在这个纯函数中引入 streaming 状态,超出本次范围。
为防止该朴素判断被悄悄应用,我新增了一个回归测试(hides sticky todos when a local slash command starts after the snapshot),使用忠实的 sentToModel: false fixture —— 当前通过,加上该判断后会失败。
|
@qwen-code /takeover |
Independent local verification of
|
| Oracle | merge-base build | PR head build |
|---|---|---|
| Idle after turn 1: panel hidden | ✅ hidden | ✅ hidden |
Turn 2 while Responding: stale panel resurfaces |
❌ resurfaces (~0.25 s after the turn starts) | ✅ never appears (polled the entire turn) |
| Turn 3: panel shows for the current turn's todos | ✅ shows | ✅ shows, with updated statuses |
3. Code-review notes — turn-boundary coverage
I checked every path that starts a turn to confirm it commits a type: 'user' history item (what hasUserMessageAfter keys on):
- Normal submits —
useGeminiStream.tsadds the USER item synchronously at submit time, so there is no pending-window flash before the boundary is visible togetStickyTodos(). - Slash commands (incl. the
/statsfrom the issue screenshot) —slashCommandProcessor.tsadds a USER item withsentToModel: false; thetypeis still'user', so the boundary triggers. - Steer — mid-turn USER item; matches the trade-off the author already documented in this thread (current-turn panel hides until the next
todo_writere-snapshots; self-healing). Agreed it's acceptable. - One small gap, non-blocking: cron-submitted prompts intentionally skip the USER history item (
submitType !== SendMessageType.Cronguard inuseGeminiStream.ts), so a cron-driven turn could still resurface the previous turn's panel. Niche enough to leave as a follow-up.
Landscape: #7061 is closed (idle case fixed by #7062, merged 07-17); this PR covers the remaining Responding-resurface scenario; no competing open PR touches this area.
中文版本 (Chinese version)
对 090ad18 的独立本地验证(合并参考)
结论:修复效果与描述完全一致 —— 新一轮对话开始后过期 todo 面板不再复现,当前轮自己的 todo 面板仍正常显示。建议合并。
在隔离的 detached worktree 中(全新 npm ci,macOS,Node 22.23.1)对已推送的 head 090ad189 做了独立验证。
1. 单元测试 A/B —— 新测试确实钉住了修复
- PR head 上运行
todoSnapshot.test.ts:21/21 通过(与 PR 描述一致)。 - 保留 PR 的测试文件、把
todoSnapshot.ts换回 merge-base(6a432ad)源码:恰好只有一个测试失败 —— "hides sticky todos when a new user message starts after the snapshot"(收到过期 todo 数组而非null)。其余 20 个双向通过,属回归守护;新测试是真正钉住修复的判别性测试。 - 其他涉及 sticky 面板的套件(
DefaultAppLayout、ScreenReaderAppLayout、app-container-controls-dep):PR head 上 22/22 通过。
2. 真实 TUI E2E A/B —— base 构建复现 bug,PR 构建修复
用 pty 驱动真实构建的 CLI(packages/cli/dist/index.js)对接脚本化的 OpenAI 兼容 mock server,分别以 merge-base 和 PR head 构建各跑一次完全相同的场景:
- Turn 1:模型调用
todo_write(3 个 todo,一个in_progress),随后长回复把内联 todo 列表滚出视口 → 两个构建在 idle 时面板都正确隐藏(fix(cli): hide sticky task panel when agent is idle #7062 行为完好)。 - Turn 2:普通用户消息 + 慢速流式回复,不带
todo_write—— 即 Sticky task panel remains visible after all tasks finish and conversation ends #7061/Repeated ECONNRESET on streaming responses when context exceeds ~150k tokens #7831 中复现的场景窗口。 - Turn 3(回归对照):同一轮内新的
todo_write+ 第二次工具调用 + 慢速回复 —— 此时面板必须出现。
每轮期间轮询屏幕内容检测 todo 字符串:
| 判定点 | merge-base 构建 | PR head 构建 |
|---|---|---|
| Turn 1 结束后 idle:面板隐藏 | ✅ 隐藏 | ✅ 隐藏 |
Turn 2 Responding 期间:过期面板复现 |
❌ 复现(新一轮开始约 0.25 秒后) | ✅ 全程轮询从未出现 |
| Turn 3:当前轮 todo 面板正常显示 | ✅ 显示 | ✅ 显示,且状态已更新 |
(截图见上方英文部分)
3. 代码审查 —— turn 边界覆盖情况
逐一核对了所有开启新一轮的路径,确认都会提交 type: 'user' 的 history item(hasUserMessageAfter 的判定依据):
- 普通消息提交 ——
useGeminiStream.ts在提交时同步添加 USER item,不存在边界对getStickyTodos()不可见的 pending 窗口。 - 斜杠命令(包括 issue 截图中的
/stats)——slashCommandProcessor.ts会添加sentToModel: false的 USER item,type仍为'user',边界生效。 - Steer —— mid-turn USER item;与作者在本线程中已说明的 trade-off 一致(当前轮面板隐藏至下次
todo_write重建快照,可自愈)。同意可接受。 - 一个不阻塞合并的小缺口:cron 提交的 prompt 有意跳过 USER history item(
useGeminiStream.ts中的submitType !== SendMessageType.Cron分支),因此 cron 驱动的新一轮理论上仍可能复现上一轮的面板。场景较偏,留作后续即可。
关联背景:#7061 已关闭(idle 场景由 #7062 修复,07-17 合并);本 PR 覆盖剩余的 Responding 复现场景;当前没有其他在开的竞争 PR 涉及此区域。
|
@qwen-code /takeover |
|
🔄 Takeover re-armed: the round counter starts a fresh window (previous rounds no longer count toward the cap); management continues. 中文说明🔄 已重新武装:轮次计数开启新窗口(此前轮次不再计入上限),托管继续。 |
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Autofix review round summary — PR #7900Feedback point 1 — Inline Critical [rc:3662969985]:
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
— qwen3.7-max via Qwen Code /review
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: No action takenThe only feedback on this PR is a There are no inline comments, no issue-level comments, no failed checks, and no still-red checks. No code changes are warranted. 中文说明未采取任何操作本 PR 上唯一的反馈来自自动审查机器人( 没有行内评论、没有 issue 级别的评论、没有失败的检查、也没有持续红色的检查。无需进行代码变更。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
@qwen-code /triage |
|
Thanks for the PR — and for the thorough root-cause analysis linking #7061, #7062, and #7831. Template looks good ✓ (the earlier template issue from the prior commit appears resolved) Problem: observed bug with clear evidence. The #7831 screenshot shows the exact scenario — model hits ECONNRESET, turn ends, user types Direction: aligned — this is a straightforward correctness fix for the sticky todo panel, squarely within the CLI's UI layer. No auth/sandbox/model-selection/telemetry concerns. Size: not applicable (no core paths touched; both files are in Approach: the scope feels right. One helper ( Risk: no elevated risk signals (no high-risk paths matched). Moving on to code review. 🔍 中文说明感谢贡献!也感谢你把 #7061、#7062、#7831 之间的因果关系梳理得很清楚。 模板完整 ✓(之前提交中的模板问题已修复) 问题:已观测到的 bug,有明确证据。#7831 截图展示了完整场景——模型遇到 ECONNRESET,轮次结束,用户输入 方向:对齐——这是 sticky todo 面板的直接正确性修复,完全在 CLI UI 层内。无 auth/sandbox/模型选择/遥测相关顾虑。 规模:不适用(未触及核心路径;两个文件均在 方案:范围合理。一个辅助函数( 风险:无升级风险信号(未匹配高风险路径)。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code ReviewIndependent proposal: given "stale sticky todos resurface when a new user message starts a new turn", I would add a guard in Comparison with the diff: the PR does exactly this. The test coverage is solid: three new cases covering (1) a new user message after the snapshot → hidden, (2) a local slash command with No critical blockers. No convention violations. The change is minimal and every line earns its place. TestingCI evidence (fetched via API for commit The ubuntu unit test suite passed. Windows/macOS tests and integration tests were skipped — standard for fork PRs where the secret-gated matrix legs don't run.
Real-scenario testing: not performed. This is a fork PR — the author lacks write access, so the sandboxed lanes ( Not verified: the "Enter to steer" edge case (panel briefly hides during steer, reappears when model updates todos). The PR's reasoning that steer implies a direction change is sound, but this path is not covered by a unit test. 中文说明代码审查独立方案: 针对"新轮次开始时过期 sticky todo 重新出现"的问题,我会在 与 diff 对比: PR 的实现完全一致。 测试覆盖扎实:三个新用例分别覆盖 (1) 快照后有新用户消息 → 隐藏,(2) 无关键阻塞项。无规范违反。改动极简,每一行都有其存在价值。 测试CI 证据(通过 API 获取,commit ubuntu 单元测试套件通过。Windows/macOS 测试和集成测试被跳过——fork PR 的标准行为(密钥隔离的矩阵分支不运行)。 真实场景测试: 未执行。这是 fork PR——作者没有写权限,沙箱通道( 未验证:"Enter to steer" 边界情况(steer 时面板短暂隐藏,模型更新 todo 后恢复)。PR 关于 steer 意味着方向改变的推理是合理的,但该路径没有单元测试覆盖。 — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 5/5 — clean across every stage; minimal, correct fix with strong test coverage. This is exactly the kind of PR I like to see. The author traced a real user-facing regression through three related issues, identified the precise gap left by the prior fix, and closed it with 17 lines of production code and 67 lines of tests. The implementation matches my independent proposal perfectly — a simple "is there a user message after the snapshot?" guard. No over-engineering, no scope creep, no unrelated changes. The "Enter to steer" tradeoff is the only wrinkle, and the PR handles it honestly: steer implies a direction change, the model typically re-issues The prior CHANGES_REQUESTED (template issue on an earlier commit) is resolved — the current PR body follows the template correctly. CI is green on ubuntu; the skipped matrix legs are standard fork-PR behavior, not a concern for a change this localized. 中文说明置信度:5/5 —— 每个阶段都很干净;极简、正确的修复,测试覆盖充分。 这正是我喜欢看到的 PR。作者通过三个相关 issue 追踪了一个真实的用户可见回归,精确定位了前一个修复留下的缺口,用 17 行生产代码和 67 行测试关闭了它。实现与我的独立方案完全一致——一个简单的"快照之后是否有用户消息?"守卫。没有过度工程,没有范围蔓延,没有无关改动。 "Enter to steer" 的权衡是唯一的褶皱,PR 诚实地处理了它:steer 意味着方向改变,模型通常很快会重新调用 之前的 CHANGES_REQUESTED(早期提交的模板问题)已解决——当前 PR body 正确遵循了模板。 CI 在 ubuntu 上绿色通过;跳过的矩阵分支是 fork PR 的标准行为,对于如此局部的改动不构成顾虑。 — Qwen Code · qwen3.8-max-preview Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
Released in v0.21.1. |



What this PR does
Prevents the sticky todo panel from resurfacing with stale todos from a previous turn. The sticky panel now checks whether a new user message exists after the todo snapshot in history; if so, the snapshot belongs to an earlier turn and the panel stays hidden. This is a pure data-layer change — no layout components or streaming-state logic are modified.
Why it's needed
Fixes #7061 (reopened scenario not covered by #7062).
PR #7062 hid the sticky panel when streaming is idle, but the panel resurfaces with stale todos as soon as the user sends a new message (state → Responding). The user sees in-progress indicators (
◐/○) for work that already finished in an earlier turn — exactly the scenario in the #7831 screenshot: the model hit ECONNRESET, the turn ended, the user typed/stats, and the old todo list reappeared.Reviewer Test Plan
How to verify
cd packages/cli && npx vitest run src/ui/utils/todoSnapshot.test.ts— confirm the two new tests pass alongside all existing ones.Evidence (Before & After)
N/A (data-layer logic change; the visual behavior is a panel not appearing when it previously did)
Tested on
Environment (optional)
Unit tests only (
npx vitest run).Risk & Scope
Enter to steerinjects a user message mid-turn, which triggers the same turn-boundary check and temporarily hides the sticky panel. Impact is minimal — steer implies a direction change so old todos are likely stale anyway, and the model typically callstodo_writeagain shortly after, which creates a new snapshot after the steer message and the panel reappears. Worst case: the panel stays hidden for the remainder of that turn if the model doesn't update todos.Linked Issues
Fixes #7061
中文说明
本 PR 做了什么
防止 sticky todo 面板在新轮次中重新显示上一轮的过期 todo。sticky 面板现在会检查 history 中 todo 快照之后是否存在新的用户消息;如果存在,说明快照属于上一轮,面板保持隐藏。这是纯数据层的修改——不涉及布局组件或流式状态逻辑。
为什么需要
修复 #7061(#7062 未覆盖的重现场景)。
PR #7062 在流式空闲时隐藏了 sticky 面板,但用户发送新消息后(状态 → Responding),面板会带着过期 todo 重新出现。用户看到的是上一轮已完成工作的进行中指示器(
◐/○)——正是 #7831 截图中的场景:模型遇到 ECONNRESET,轮次结束,用户输入/stats,旧 todo 列表重新出现。审阅者测试计划
如何验证
cd packages/cli && npx vitest run src/ui/utils/todoSnapshot.test.ts——确认两个新测试与所有现有测试一起通过。证据(前后对比)
N/A(数据层逻辑变更;视觉行为是面板在之前会显示时不再显示)
测试环境
环境(可选)
仅单元测试(
npx vitest run)。风险与范围
Enter to steer在模型响应过程中注入用户消息,会触发相同的轮次边界检查,临时隐藏 sticky 面板。实际影响很小——steer 本身意味着用户改变了方向,旧 todo 大概率已不适用;且模型收到 steer 后通常会很快更新 todo,新快照在 steer 消息之后,面板自动恢复。最坏情况:steer 后模型没更新 todo,面板在该轮剩余时间不显示。关联 Issue
修复 #7061