Skip to content

fix(web-shell): message edit fails closed while the transcript window is incomplete - #10419

Closed
yiliang114 wants to merge 9 commits into
codex/vscode-web-shell-cutoverfrom
fix/issue-10385-rewind-turn-index
Closed

fix(web-shell): message edit fails closed while the transcript window is incomplete#10419
yiliang114 wants to merge 9 commits into
codex/vscode-web-shell-cutoverfrom
fix/issue-10385-rewind-turn-index

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Message edit in the WebShell transcript computed the rewind target from the rendered transcript window: MessageList numbers role === 'user' messages inside the loaded messages window and passed that window-local ordinal to onEditUserMessage, while the daemon's rewind snapshots are indexed session-globally. This PR fails closed: the edit affordance is no longer offered while the window is known incomplete — hasOlderHistory (older pages not loaded yet) or historyCapacityReached (older blocks dropped for window capacity). When the window is complete the behavior is unchanged.

Why it's needed

When the session's user-turn count exceeds the loaded transcript window, the window-local ordinal of the last user message is smaller than its session-global turn index. Editing the last message then either resolves to an earlier snapshot than requested (silent over-truncation of session history) or finds no matching snapshot and fails with rewind.empty after the composer was already replaced (Fixes #10385, split out of the review on #9811).

Reviewer Test Plan

How to verify

Component-level reproduction against the real MessageList (in packages/web-shell/client/components/MessageList.dom.test.tsx, describe user message edit affordance (issue #10385)): render a window containing only the last two of five session-global user turns with hasOlderHistory={true} (and separately historyCapacityReached={true}) and an onEditUserMessage spy.

  • Before this PR: the edit affordance is still rendered on the last user message and invoking it passes the window-local ordinal (1 instead of the session-global 4). The two incomplete-window tests fail.
  • After this PR: no edit affordance is offered while the window is incomplete; the two tests pass.

Regression tests in the same block: with a complete window, editing is offered only on the last user message and passes its ordinal (unchanged behavior), and user_shell echoes do not skew the numbering — they carry role: 'user_shell', not 'user', so the shell-echo distortion suggested in the issue does not exist at the message level (pinned by the test).

cd packages/web-shell
npx vitest run --config vitest.config.ts client/components/MessageList.dom.test.tsx

Result: Test Files 1 passed (1), Tests 162 passed (162) (includes the 4 new tests). npm run typecheck, eslint, and prettier --check are clean on the touched files.

Evidence (Before & After)

Before (on this branch without the MessageList.tsx gate, new tests only):

FAIL  components/MessageList.dom.test.tsx > user message edit affordance (issue #10385) > does not offer editing while older history is unloaded
AssertionError: expected <button data-testid="edit-u5" …> to be null
FAIL  components/MessageList.dom.test.tsx > user message edit affordance (issue #10385) > does not offer editing when older history was dropped for window capacity
AssertionError: expected <button data-testid="edit-u5" …> to be null
Tests  2 failed | 2 passed | 158 skipped (162)

After (with the gate): Tests 162 passed (162).

Tested on

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

Environment (optional)

Unit/component tests only (vitest, jsdom): npm ci at repo root on the #9811 head 86c1c689, then the vitest/typecheck/eslint/prettier commands above inside packages/web-shell.

Risk & Scope

  • Main risk or tradeoff: editing the last user message is unavailable in long sessions whose transcript window is incomplete (paginated or capacity-trimmed) — the fail-closed scope suggested in the issue; the session-global ("class-closing") index source is left as follow-up.
  • Not validated / out of scope: daemon-side snapshot indexing for cron/notification turns; RewindDialog.promptTextForTurn display-text numbering noted in the issue thread; end-to-end run against a live daemon.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #10385

This PR targets codex/vscode-web-shell-cutover because the defect only exists on the #9811 branch (main has no edit affordance); the fix rides into main with #9811.

中文说明

这个 PR 做了什么

WebShell 会话记录里的"编辑消息"原来从渲染出来的转录窗口计算回退目标:MessageList 只对已加载 messages 窗口内的 role === 'user' 消息编号,并把这个窗口内局部序号传给 onEditUserMessage;而守护进程的 rewind 快照是按会话全局索引的。本 PR 采用失败即关闭策略:当窗口已知不完整时(hasOlderHistory,更早的分页尚未加载;或 historyCapacityReached,更早的块因窗口容量被丢弃)不再提供编辑入口。窗口完整时行为不变。

为什么需要

当会话的用户轮数超过已加载的转录窗口时,最后一条用户消息的窗口内局部序号会小于它的会话全局轮号。此时编辑最后一条消息要么命中比预期更早的快照(静默过度截断会话历史),要么找不到匹配快照、在 composer 已被替换后才报 rewind.empty(修复 #10385,从 #9811 的 review 中拆出)。

Reviewer 测试计划

如何验证

针对真实 MessageList 的组件级复现(位于 packages/web-shell/client/components/MessageList.dom.test.tsx,describe 名为 user message edit affordance (issue #10385)):构造"会话全局共 5 个用户轮、窗口只含最后 2 个"的场景,分别传 hasOlderHistory={true}historyCapacityReached={true},并挂 onEditUserMessage 间谍。

  • 本 PR 之前:最后一条用户消息上仍渲染编辑入口,触发后传入窗口内局部序号(1 而非会话全局的 4)。两个不完整窗口用例失败。
  • 本 PR 之后:窗口不完整时不再提供编辑入口;两个用例通过。

同一块里的回归用例:窗口完整时编辑只在最后一条用户消息上提供且传入其序号(行为不变);user_shell 回显不影响编号——它们的角色是 user_shell 而非 user,因此 issue 中提到的 shell 回显序号偏差在消息层并不存在(已用测试钉住)。

cd packages/web-shell
npx vitest run --config vitest.config.ts client/components/MessageList.dom.test.tsx

结果:Test Files 1 passed (1)Tests 162 passed (162)(含 4 个新用例)。改动文件的 npm run typecheckeslintprettier --check 均通过。

证据(修改前后)

修改前(在本分支上去掉 MessageList.tsx 门控、只保留新测试):

FAIL  components/MessageList.dom.test.tsx > user message edit affordance (issue #10385) > does not offer editing while older history is unloaded
AssertionError: expected <button data-testid="edit-u5" …> to be null
FAIL  components/MessageList.dom.test.tsx > user message edit affordance (issue #10385) > does not offer editing when older history was dropped for window capacity
AssertionError: expected <button data-testid="edit-u5" …> to be null
Tests  2 failed | 2 passed | 158 skipped (162)

修改后(加门控):Tests 162 passed (162)

测试环境

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

环境(可选)

仅单元/组件测试(vitest、jsdom):在 #9811 head 86c1c689 上仓库根目录 npm ci,然后在 packages/web-shell 内执行上述 vitest/typecheck/eslint/prettier 命令。

风险与范围

  • 主要风险或取舍:在转录窗口不完整(分页未加载完或容量裁剪)的长会话中,最后一条用户消息不可编辑——即 issue 建议的失败即关闭最小范围;会话全局的"彻底闭环"索引来源留作后续。
  • 未验证 / 超出范围:守护进程侧 cron/notification 轮的快照索引;issue 线程中提到的 RewindDialog.promptTextForTurn 展示文本编号;对真实运行守护进程的端到端验证。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #10385

本 PR 以 codex/vscode-web-shell-cutover 为目标分支,因为缺陷只存在于 #9811 分支上(main 没有编辑功能);修复将随 #9811 一起进入 main。

Message edit computed the rewind target from the rendered transcript
window (a window-local user-turn ordinal), while the daemon's rewind
snapshots are indexed session-globally. When the session history exceeds
the loaded window, editing the last user message rewound to an earlier
snapshot than requested (silent history loss) or failed with
`rewind.empty` after the composer was already replaced.

Fail closed: do not offer the edit affordance while older history is
unloaded (hasOlderHistory) or was dropped for window capacity
(historyCapacityReached). The shell-echo numbering skew suggested in the
issue does not apply at the message level (echoes carry role
`user_shell` and are excluded from numbering); a regression test pins
this, along with the complete-window behavior.

Fixes #10385

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for splitting this out of the #9811 review!

Moving on to code review. 🔍

中文说明

感谢把这个修复从 #9811 的 review 中拆出来!

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at 9a657635b594e7668f0ff3617a69c9768fba1619 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

My independent take before reading the diff: for a split-out fix riding #9811, the right scope is a fail-closed gate on the affordance now, with the authoritative session-global index source deferred — the alternative touches the daemon SDK boundary and is its own project. The PR does exactly this, so the approach matches what I would have shipped.

Verified against the base branch code (86c1c689):

  • The gate sits at the only production entry point. onEditUserMessage flows App.tsxMessageListMessageItemUserMessage, and UserMessage renders the edit button only when onEdit is truthy — so the two added conditions withdraw the affordance everywhere.
  • Both signals are real and wired. hasOlderHistory / historyCapacityReached are existing MessageList props defaulting to false, fed in production from transcriptHistory.hasMore / transcriptHistory.capacityReached in App.tsx. The gate covers both incompleteness shapes: unloaded older pages (recoverable by scrolling up to paginate) and capacity-dropped blocks (permanent for the session — the correct fail-closed call).
  • Memo hygiene is correct. Both props were added to the render callback's dependency array, so the gate cannot go stale when pagination state changes.
  • The shell-echo concern doesn't exist at the message level. The ordinal numbering counts role === 'user' only, and shell echoes carry role: 'user_shell' — confirmed in base code, and the new test pins it there.
  • The issue's mechanism description is accurate. App.tsx resolves the rewind target via snapshots.find(entry => entry.turnIndex === turnIndex) and throws rewind.empty on no match — exactly the mismatch fix(web-shell): message edit passes window-local turn index to session-global rewind snapshots #10385 describes.

No critical findings. Non-blocking notes:

Test evidence

This stacked PR has no CI coverage by design, not by accident: Qwen Code CI triggers on pull_request to main/release/** only, and this PR targets the #9811 branch. The full suite gates this code when it rides into main with #9811. The only checks on this commit are the bot-orchestration ones:

Check Conclusion
authorize success
label success
delay-automatic-review success
verify skipped
tmux-testing skipped
precheck-pr skipped
review-pr in_progress
triage in_progress

Author-reported results (clearly attributed, not independently re-run — triage never executes PR code): 162/162 tests pass in MessageList.dom.test.tsx including the 4 new ones, and with the gate removed the two incomplete-window tests fail while the regression tests still pass — the right pinning shape. Not verified: independent execution of the suite, structurally unavailable on this branch.

Sandboxed verification would settle this: @qwen-code /verify — that the new gate actually withdraws the edit affordance in incomplete windows rests on the author-reported run since no CI exists on stacked branches; an A/B against the base build (the two tests fail without the gate, pass with it) would make that load-bearing proof.

中文说明

代码审查

看 diff 之前我的独立判断:对随 #9811 进入的拆分修复,正确的范围是现在先做失败即关闭的门控,把会话全局的权威索引来源留到后续——后者要动 daemon SDK 边界,是另一个项目。PR 正是这么做的,与我会采用的方案一致。

已对照基线分支代码(86c1c689)核实:

  • 门控位于唯一的生产入口。 onEditUserMessage 的链路是 App.tsxMessageListMessageItemUserMessage,且 UserMessage 只在 onEdit 为真值时渲染编辑按钮——所以新增的两个条件能在全局收回编辑入口。
  • 两个信号都是真实且已接线的。 hasOlderHistory / historyCapacityReachedMessageList 现有 props,默认 false,生产中由 App.tsxtranscriptHistory.hasMore / transcriptHistory.capacityReached 提供。门控覆盖了两种不完整形态:未加载的更早分页(向上滚动分页后可恢复编辑)和容量裁剪丢弃的块(该会话内永久不可编辑——这是正确的失败即关闭取舍)。
  • memo 依赖正确。 两个 prop 都加入了渲染回调的依赖数组,分页状态变化时门控不会过期。
  • shell 回显的担忧在消息层不存在。 序号编号只统计 role === 'user',shell 回显的 role 是 user_shell——已在基线代码中确认,新测试也钉住了这一点。
  • issue 对机制的描述准确。 App.tsx 通过 snapshots.find(entry => entry.turnIndex === turnIndex) 解析回退目标,未命中时抛 rewind.empty——正是 fix(web-shell): message edit passes window-local turn index to session-global rewind snapshots #10385 描述的错位。

无阻塞性问题。非阻塞备注:

测试证据

这个堆叠 PR 按设计就没有 CI 覆盖,不是意外Qwen Code CI 只对目标为 main/release/**pull_request 触发,而本 PR 目标是 #9811 的分支。完整测试套件会在修复随 #9811 进入 main 时把关。此提交上只有 bot 编排类检查(见英文部分的表格)。

作者自报结果(明确标注为作者声明,未独立复跑——triage 从不执行 PR 代码):MessageList.dom.test.tsx 162/162 通过(含 4 个新用例);去掉门控后两个不完整窗口用例失败、回归用例仍通过——钉住的形状正确。未验证:套件的独立执行,在本分支上结构性不可用。

沙箱验证可以补上这一环:@qwen-code /verify —— 由于堆叠分支没有 CI,新门控是否真的在不完整窗口下收回编辑入口目前只有作者自报结果支撑;对基线构建做 A/B(去掉门控两个用例失败、加上则通过)可以把这一点变成承重证据。

Qwen Code · qwen3.8-max

Reviewed at 9a657635b594e7668f0ff3617a69c9768fba1619 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean static review of a minimal, correctly-scoped fail-closed fix; the one deduction is that the test suite could not be independently executed on this branch (structural, see below).

Stepping back: this PR does one thing and does it the way I would have done it. The independent proposal for a split-out fix riding #9811 was exactly "gate the affordance now, source the index authoritatively later", and the diff is precisely that minimal set — two added conditions, two added memo deps, four tests. No drive-by changes, no scope creep, and the comment on the gate explains the invariant for the next reader. The tests pin both trigger conditions plus the complete-window regression, and the whole mechanism was traced end-to-end against the base branch — the gate can only err toward "editing unavailable", never toward "rewinds too far", which is the right trade for a data-loss-adjacent bug. Six months from now this reads as an honest, bounded fix, not debt.

The 4/5 rather than 5/5 is the testing gap, stated plainly: stacked branches receive no CI by design (Qwen Code CI is scoped to main/release/**), so the only test signal is the author-reported run, attributed as such in Stage 2. Approving without a green CI table is unusual and worth naming: it rests on (a) the gate being structurally absent here rather than red, (b) the full suite gating this code when it rides into main with #9811, and (c) @qwen-code /verify being available above for anyone who wants the A/B proof before then.

The only loose end is the one from Stage 1: give the class-closing half of #10385 a follow-up issue when this lands.

Verdict: approve, pinned to the reviewed commit. ✅

中文说明

置信度:4/5 —— 对一个范围正确、最小化的失败即关闭修复的干净静态审查;唯一扣分项是本分支上无法独立执行测试套件(结构性原因,见下)。

退一步看:这个 PR 只做一件事,而且做法与我会采用的一致。对随 #9811 进入的拆分修复,独立判断的方案就是"现在门控编辑入口,索引来源的权威化留到后续",而 diff 恰好是这个最小集合——两个新增条件、两个新增 memo 依赖、四个测试。没有顺手改动,没有范围蔓延,门控上的注释也为后来者解释了不变量。测试钉住了两个触发条件和完整窗口的回归行为,整个机制也已在基线分支上端到端核实——门控只可能偏向"不可编辑",绝不会偏向"回退过头",对一个接近数据丢失的 bug 来说这是正确的取舍。六个月后回看,这是一个诚实、有边界的修复,不是债务。

给 4/5 而非 5/5 是测试缺口,直说:堆叠分支按设计没有 CI(Qwen Code CI 只对 main/release/** 触发),所以唯一的测试信号是作者自报结果,已在 Stage 2 中明确标注。没有绿色 CI 表格就批准并不寻常,值得说明理由:(a) 这里是结构上没有门禁,而不是门禁红了;(b) 完整套件会在修复随 #9811 进入 main 时把关;(c) 上面已给出 @qwen-code /verify,任何人在那之前想要 A/B 承重证据都可以触发。

唯一的遗留点是 Stage 1 提到的:本 PR 落地时,为 #10385 的彻底闭环部分建一个后续 issue。

结论:批准,钉在所审查的提交上。✅

Qwen Code · qwen3.8-max

Reviewed at 9a657635b594e7668f0ff3617a69c9768fba1619 · re-run with @qwen-code /triage

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": verify the default maxBlocks / maxRetainedBytes cap values (exported as DAEMON_SESSION_DEFAULT_MAX_BLOCKS ; definition not located in budget) to quantify how…; "agent reverse-audit (round 1)": trace LiveMessageList embedders (e.g. split-view/side-task usages) to confirm they pass hasOlderHistory / historyCapacityReached through props.; "agent reverse-audit (round 1)": check whether cron/notification promptIds ( ########cron… , ########notification… ) create FHS snapshots that shift the global idx space and could make a con…; "agent 5": none — no check was cut short.; "agent reverse-audit (round 3)": did not execute the VS Code embedded flow end-to-end to observe the composer.editExpired toast live (static trace only, every hop read at HEAD)..

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Test Plan (not a blocker): client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory; Tests 162 passed — this review observed 4943, 450 passed; 2 passed — this review observed 4943, 450 passed.

中文说明

仅完成部分审查,审查缺口已披露。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"verify the default maxBlocks / maxRetainedBytes cap values (exported as DAEMON_SESSION_DEFAULT_MAX_BLOCKS ; definition not located in budget) to quantify how…"agent reverse-audit (round 1)"trace LiveMessageList embedders (e.g. split-view/side-task usages) to confirm they pass hasOlderHistory / historyCapacityReached through props."agent reverse-audit (round 1)"check whether cron/notification promptIds ( ########cron… , ########notification… ) create FHS snapshots that shift the global idx space and could make a con…"agent 5"none — no check was cut short."agent reverse-audit (round 3)"did not execute the VS Code embedded flow end-to-end to observe the composer.editExpired toast live (static trace only, every hop read at HEAD).

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

Test Plan(非阻断):client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory; Tests 162 passed — this review observed 4943, 450 passed; 2 passed — this review observed 4943, 450 passed

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment thread packages/web-shell/client/components/MessageList.tsx
expect(onEditUserMessage).toHaveBeenCalledWith(1, 'q');
});

it('does not count user_shell echoes when numbering user turns', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-2: This test pins only half of the echo-skew question the PR description settles. editableUserTurn numbers every rendered role === 'user' message, but rewind snapshots are indexed by admitted daemon turns only (Session.ts makeSnapshot deliberately skips locally handled commands). Locally echoed slash commands — /stats, /status, /about, /context; none are hidden by VSCODE_HIDDEN_SLASH_COMMANDS — append kind: 'user' blocks with no meta via echoLocalCommandIfIdle (App.tsx:10498) → appendLocalUserTranscriptMessage, and the adapter renders them role: 'user', so they ARE counted while every gate flag correctly stays false on a complete window. The affordance then lands on the echo row itself, or passes an inflated ordinal for the next real prompt; editUserMessage finds no snapshot at that index → rewind.empty after the composer was replaced (standalone), or composer.editExpired at re-submit with subsequent sends kept cancelling until the editing state is cleared (the shipped VS Code flow, EmbeddedApp.tsx:1371-1414). One routine /stats breaks message editing for the rest of the live session view; the drift clears only on reload. A second entrance of the same root: a definitely-rejected send's optimistic block persists (only attachments are removed) and is counted too, with no snapshot counterpart. The user_shell case pinned here covers !-style shell echoes only — the description's "no numbering distortion at the message level" holds for those, not for slash-command echoes.

Witness (probe in the PR's own harness at the reviewed commit; it flips):

[..., echo('/stats')]                 → edit button rendered ON the echo row; onEditUserMessage(2, '/stats')
[..., echo, ..., userMsg('u2')]       → clickEdit('u2') passes (2, 'q') against daemon snapshot indices {0,1}
same array minus the echo             → clickEdit('u2') passes (1, 'q')  ← matches the daemon index

Suggested fix: tag local echoes at creation (e.g. meta: { source: 'local_command' } in appendLocalUserTranscriptMessage — the adapter already lifts meta.source) and skip such blocks in the editableUserTurn producer (both the numbering and the lastId candidate); applying the same marker to never-dispatched optimistic blocks also closes the rejected-send entrance. Failing closed whenever such a block is present is the minimal in-PR alternative.

Fix acceptance criterion: a DOM test mounting [userMsg('u1'), asstMsg('a1'), localEchoMsg('e1'), userMsg('u2')] with the local marker — assert no edit-e1 button and clickEdit(c, 'u2') calling onEditUserMessage with (1, 'q'); removing the producer skip must turn it red.

中文说明

这个测试只钉住了 PR 描述所下的“回显序号偏差”结论的一半。editableUserTurn 对每一个渲染出的 role === 'user' 消息编号,但回退快照只按守护进程真正受理的轮次索引(Session.ts 的 makeSnapshot 有意跳过本地处理的命令)。本地回显的 slash 命令 —— /stats/status/about/context,均未被 VSCODE_HIDDEN_SLASH_COMMANDS 隐藏 —— 经 echoLocalCommandIfIdle(App.tsx:10498)→ appendLocalUserTranscriptMessage 追加不带 meta 的 kind: 'user' 块,适配器把它们渲染为 role: 'user',因此它们会被计入编号,而窗口完整时所有门控标志都正确地保持为 false。于是编辑入口要么落在回显行本身上,要么为下一条真实消息传入偏大的序号;editUserMessage 在该索引上找不到快照 → 独立端在 composer 已被替换后抛 rewind.empty;发货的 VS Code 流程(EmbeddedApp.tsx:1371-1414)则在重新提交时抛 composer.editExpired,并在编辑状态被清除前持续取消后续发送。一次再平常不过的 /stats 就会让当前会话视图里的消息编辑在整个会话期间失效(只有重载才恢复)。同一根因的第二个入口:被彻底拒绝的发送留下的乐观块仍然存在(只移除了附件)且同样被计入编号,却没有对应快照。这里钉住的 user_shell 用例只覆盖 ! 形式的 shell 回显 —— 描述中“消息层不存在序号偏差”的结论对 shell 回显成立,对 slash 命令回显不成立。

验证探针(在被审提交上用 PR 自带的测试框架运行,可翻转):含回显时编辑按钮渲染在回显行上、传入 (2, '/stats');回显之后的真实消息点击编辑传入 (2, 'q'),而守护进程快照索引只有 {0,1};去掉回显后传入 (1, 'q'),与守护进程索引一致。

建议修复:在创建时给本地回显打标记(如在 appendLocalUserTranscriptMessage 中加 meta: { source: 'local_command' } —— 适配器已提升 meta.source),并在 editableUserTurn 生产者中跳过这类块(编号与 lastId 候选都跳过);同一标记用于从未分发的乐观块即可同时关闭被拒发送的入口。退一步的最小方案:只要存在这类块就失败关闭。

验收标准:新增 DOM 测试,以 [userMsg('u1'), asstMsg('a1'), localEchoMsg('e1'), userMsg('u2')](回显带本地标记)挂载,断言没有 edit-e1 按钮、clickEdit(c, 'u2')(1, 'q') 调用 onEditUserMessage;移除生产者中的跳过逻辑必须让该测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

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.

Confirmed real at the reviewed head, but escalating instead of fixing in this pass — the correct fix exceeds the 3-file budget for this worker and needs a design decision.

Verified evidence:

  1. Snapshots skip local commands: Session.ts (~5232) places makeSnapshot after the slash-command/hook early returns, with a comment stating locally handled commands must not create phantom snapshots that desync the snapshot index.
  2. Echo path: echoLocalCommandIfIdle (App.tsx:7356; call sites 10498/10514 for /stats, /status, /about) → store.appendLocalUserMessage(text) with no meta → adapter renders role: 'user' (transcriptToMessages.ts:470-483 lifts meta.source when present) → counted by editableUserTurn (MessageList.tsx:2892, counts every role === 'user').
  3. /stats//status//about//context are absent from VSCODE_HIDDEN_SLASH_COMMANDS (EmbeddedApp.tsx:83-103).
  4. Rejected-send entrance confirmed: on a definite admission rejection the optimistic block persists with attachments stripped (actions.ts ~929-941) and no user.text.delta ever merges into it.

Design questions blocking a worker-pass fix:

  • Tagging surface spans ≥4 files: App.tsx echo wrapper, ChatPane.tsx direct echoes (/goal ~780, /context ~1054 — the latter is a toolbar button, not a rare path), the editableUserTurn producer in MessageList.tsx, plus tests.
  • Marker mechanism matters: appendTextDelta merges event meta into the existing block meta (existing.meta = { ...existing.meta, ...event.meta }), so tagging optimistic prompt blocks would survive daemon admission and wrongly skip admitted turns from numbering. Only never-dispatched blocks may be tagged, which argues for per-call-site tagging or an SDK-level local-origin flag (API change).
  • Semantics decision: skip-and-renumber vs fail-closed-while-present for the echo case, and how the rejected-send entrance should interact with the retry affordance.

Leaving unresolved for maintainer decision.

Comment thread packages/web-shell/client/components/MessageList.tsx
Comment thread packages/web-shell/client/components/MessageList.tsx
yiliang114 and others added 2 commits August 29, 2026 05:10
A non-retryable load-older failure latches the session provider at
hasMore=false, capacityReached=false, paginationError=true while the
rendered window is still missing older turns. The edit gate only
checked the first two flags, so the affordance reappeared and passed a
window-local ordinal to the session-global rewind snapshot lookup —
the #10385 defect class. Include historyPaginationError in the gate
and in the render callback's dependency array.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The new gate flags were pinned by no test: every affordance case
mounted once with fixed props. Extend rerenderMessages to forward the
edit callback and history flags, then flip hasOlderHistory and
historyCapacityReached without changing the messages array identity.
Dropping the flag entries from the render callback's dependency array
makes both transition tests fail (mutation-probed).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.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.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-3 window-completeness invariant enforced at a single consumption site — already reported (comment 3883274771); author declined with verified single-consumer evidence and resolved

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": full trace of the session-switch commit ordering in DaemonSessionProvider (loadSession/clearSession vs store.reset vs connection.sessionId) to confirm or rule o…; "agent reverse-audit (round 1)": daemon-side confirmation that the rewind-snapshot turn ledger (FileHistoryService idx + getRewindableUserTurnCount) matches the client role === 'user' count f…; "agent 1a": run the new vitest suite ( packages/web-shell MessageList.dom.test.tsx ) to confirm it is green — the review worktree has no node_modules , so execution woul….

Test Plan (not a blocker): client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory.

中文说明

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"full trace of the session-switch commit ordering in DaemonSessionProvider (loadSession/clearSession vs store.reset vs connection.sessionId) to confirm or rule o…"agent reverse-audit (round 1)"daemon-side confirmation that the rewind-snapshot turn ledger (FileHistoryService idx + getRewindableUserTurnCount) matches the client role === 'user' count f…"agent 1a"run the new vitest suite ( packages/web-shell MessageList.dom.test.tsx ) to confirm it is green — the review worktree has no node_modules , so execution woul…

Test Plan(非阻断):client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +5450 to +5452
!hasOlderHistory &&
!historyCapacityReached &&
!historyPaginationError &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-1: The three flags negated here are treated as a complete signal that the window-local user-turn ordinal equals the session-global rewind-snapshot index, but the history provider has incomplete-window states that none of the three express. Proven by running the real provider: recordIds are stamped only during replay of persisted transcripts, never on the live event stream (bridge.ts ~6660), so when a client-side retention trim evicts the oldest blocks of a session lived from creation, onTruncation fires with no retained block carrying a recordId; the provider's fail-closed branch (DaemonSessionProvider.tsx ~858-877) then drops hasMore=false while preserving the other two flags at false — nothing records the eviction. The edit affordance is then offered on the last user message of a window that is missing persisted older turns; clicking it passes the window-local ordinal to editUserMessage (App.tsx:9439), which matches it against session-global snapshots and silently rewinds to an earlier turn, discarding everything after it — exactly the #10385 defect this gate exists to close.

Witness (real DaemonSessionProvider run, jsdom harness, maxBlocks: 2, live-only session — 3 user turns streamed, then count-trimmed):

PROBE-B observed: {"hasMore":false,"capacityReached":false,"paginationError":false,"loading":false,"windowKinds":["user","assistant"],"userBlocksInWindow":1,"userTurnsStreamed":3}

The window holds 1 of 3 user turns while all three gate flags read false. Two sibling states share the same root: the re-open byte gate (~836-856) only re-arms hasMore when postTrimRetainedBytes < byteCap — a single oversized block keeps it false after the trim; and the initial replay load (~1827-1834) computes historyHasMore false whenever the daemon lacks session_transcript_pagination (older external daemons) or no anchor recordId resolves, even when the replay itself was truncated. Default caps are large (50k blocks / 128MiB), so production reach needs a long pure-live session or a host-configured smaller window — but once reached, the state is deterministic.

Suggested fix: express window-completeness positively instead of as the complement of the three load flags — on an anchor-less eviction trim (and the truncated-replay initial-load states), have the provider latch a signal the gate can read (e.g. set capacityReached=true there, or add a fourth historyWindowIncomplete flag), and add it to this gate and to the render-callback dependency array below.

Fix acceptance criterion: a DaemonSessionProvider test asserting that after an anchor-less eviction trim the exposed history state still signals the window is incomplete, plus a case in this suite mounting with that signal set and asserting [data-testid="edit-u5"] is null — removing either the provider latch or the gate check must turn them red.

中文说明

这里取反的三个标志被当作"窗口内局部用户轮序号等于会话全局回退快照索引"的完备信号,但历史提供器存在这三者都无法表达的不完整窗口状态。通过运行真实提供器证实:recordId 只在重放持久化转录时打标,实时事件流上从不打标(bridge.ts ~6660),因此当一个从头活跃的会话被客户端保留策略裁剪掉最旧的块时,onTruncation 触发而留存块中没有任何一个携带 recordId;提供器的失败关闭分支(DaemonSessionProvider.tsx ~858-877)随即把 hasMore 置为 false,另外两个标志保持 false —— 驱逐发生得无声无息。此时窗口明明缺少已持久化的更早轮次,编辑入口却仍会出现在最后一条用户消息上;点击后把窗口内局部序号传给 editUserMessage(App.tsx:9439),后者按会话全局快照匹配,静默回退到更早的轮次,丢弃其后所有内容 —— 正是本门控要关闭的 #10385 缺陷。

验证证据(真实 DaemonSessionProvider 运行,jsdom 框架,maxBlocks: 2,纯实时会话 —— 流式输入 3 个用户轮后按数量裁剪):窗口只剩 3 个用户轮中的 1 个,而三个门控标志全部为 false(见上方 PROBE-B 输出)。

同根因还有两个兄弟状态:重开字节门(~836-856)仅在 postTrimRetainedBytes < byteCap 时才重新置位 hasMore —— 单个超大块就能让裁剪后仍不满足;初始重放加载(~1827-1834)在守护进程不支持 session_transcript_pagination(较老的外部守护进程)或无法解析锚点 recordId 时,即使重放本身被截断,也会把 historyHasMore 算成 false。默认上限很大(5 万块 / 128MiB),生产上需要一个长时间的纯实时会话或宿主配置的更小窗口才会触发 —— 但一旦触发,状态是确定性的。

建议修复:用正向信号表达窗口完整性,而不是三个加载标志的取反 —— 在无锚点驱逐裁剪(以及截断重放的初始加载状态)时,让提供器置位一个门控可读的信号(如在那里置 capacityReached=true,或新增第四个 historyWindowIncomplete 标志),并把它加入本门控和下方的渲染回调依赖数组。

验收标准:DaemonSessionProvider 测试断言无锚点驱逐裁剪后暴露的历史状态仍标记窗口不完整;并在本套件中新增一个以该信号挂载的用例,断言 [data-testid="edit-u5"] 为 null —— 移除提供器置位或门控检查都必须让测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

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.

Confirmed real at this head by code read — escalating instead of fixing in this pass, the correct fix is a provider-contract change beyond this worker's budget.

Verified evidence in DaemonSessionProvider.tsx:

  1. Anchor-less live-trim branch (~856-874): the state update is gated on if (history.hasMore). A live-only session loads with hasMore=false (initial replay load ~1827 computes historyHasMore=false with no persisted history), so when a retention trim evicts oldest blocks and no retained block carries a recordId, nothing records the eviction — all three gate flags stay false on a truncated window. Matches the reported PROBE-B shape exactly.
  2. Re-anchor branch byte gate (~836-856): hasMore is only re-armed when olderHistoryReachable (pagination feature && postTrimRetainedBytes < byteCap); a single oversized block leaves every flag false after the trim.
  3. capacityReached is latched only on the replay-rebuild trim (~2184) and rejected-page (~3761) paths — never on live streaming trims.
  4. recordIds flow in only via evidenceCursor on replayed persisted records (mappers.ts ~602); live-streamed blocks carry none, which is what makes live trims anchor-less.
  5. maxBlocks / maxRetainedBytes are host-configurable provider options (types.ts), so the state is reachable deterministically once a small window is configured or defaults are outlived.

Design questions blocking a worker-pass fix:

  • No existing flag can express all three states honestly: latching capacityReached=true misstates the truncated-replay initial-load state (no capacity event occurred) and feeds the provider's own re-open-on-eviction logic (~884) and rejected-page footprint accounting; paginationError is not an error; hasMore=true would offer a load-older affordance the exclusive-before anchor contract can never satisfy — exactly what the fail-closed branch comment refuses.
  • The honest fix is a positive window-completeness signal (e.g. a fourth historyWindowIncomplete flag), which is a provider API change: types.ts + DaemonSessionProvider.tsx (three latch sites) + both wiring call sites (App.tsx 13247-13252, ChatPane.tsx 1318-1321) + the MessageList gate/deps + tests — over the 3-file budget, with flag naming/semantics and the older-daemon truncated-replay case to decide.

Leaving unresolved for maintainer decision.

Comment thread packages/web-shell/client/components/MessageList.dom.test.tsx
Comment thread packages/web-shell/client/components/MessageList.tsx
…flip test

The historyPaginationError entry in the memoized render-callback
dependency array had no re-render transition test: a mutation removing
the entry kept the whole suite green because the mount-level
pagination-error case creates the callback with current values on first
render. Add a sibling-style flip test (mount with the flag set, re-render
the identical messages array with it cleared) that goes red when the
entry is removed, mirroring the existing hasOlderHistory and
historyCapacityReached transition tests.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.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.

Not explored to full depth (tool budget reached): "agent 1a": running the new DOM tests under vitest — the review worktree has no node_modules (root or packages/web-shell ), and a full monorepo install plus prerequisite….

Test Plan (not a blocker): client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory.

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/web-shell/client/components/MessageList.dom.test.tsx:6534 — [probe] clickEdit re-implements the file-scoped click helper
  • packages/web-shell/client/components/MessageList.dom.test.tsx:6530 — [probe] no test pins the edit gate's !isResponding condition
  • packages/web-shell/client/components/MessageList.dom.test.tsx:6549 — [probe] unflushed auto load-older chain emits act warnings
  • packages/web-shell/client/components/MessageList.dom.test.tsx:6581 — [probe] no test pins the affordance on an assistant-tailed window
  • packages/web-shell/client/components/MessageList.dom.test.tsx:6600 — [probe] prepend/grown-window turn-index recompute never exercised
  • packages/web-shell/client/components/MessageList.dom.test.tsx:6587 — [probe] content argument provenance never pinned
  • packages/web-shell/client/components/MessageList.dom.test.tsx:6603 — [probe] no false->true flag-flip (withdrawal) test

[Critical] R2-1: This round's re-check rules this blocker still stands — the gate (MessageList.tsx:5450-5452) still reads exactly the three flags (!hasOlderHistory && !historyCapacityReached && !historyPaginationError), MessageList.tsx is unchanged since the finding, and the provider states this entry names are live at this head: on an anchor-less retention trim of a live-only session the fail-closed branch (DaemonSessionProvider.tsx ~857-874) latches hasMore=false while capacityReached and paginationError stay false, so the window is missing persisted older turns while all three gate flags read false; the edit affordance is then offered on the last user message and clicking it passes the window-local ordinal to editUserMessage (App.tsx:9439), which matches it against session-global snapshots and silently rewinds to an earlier turn, discarding everything after it — exactly the #10385 defect this gate exists to close. Round-2 probe on the real provider observed the window holding 1 of 3 user turns while all three flags read false; round-3 re-read the anchor-less branch at dfec5b5, and the author confirmed real and escalated for the provider-contract fix. Suggested fix: express window-completeness positively instead of as the complement of the three load flags — on an anchor-less eviction trim (and the truncated-replay initial-load states) have the provider latch a signal the gate can read (e.g. set capacityReached=true there, or add a fourth historyWindowIncomplete flag) — and add it to this gate and the render-callback dependency array. Fix acceptance criterion: a DaemonSessionProvider test asserting that after an anchor-less eviction trim the exposed history state still signals the window is incomplete, plus a case in this suite mounting with that signal set and asserting [data-testid=edit-u5] is null — removing either the provider latch or the gate check must turn them red.

中文说明

未探索到全部深度(达到工具调用预算):"agent 1a"running the new DOM tests under vitest — the review worktree has no node_modules (root or packages/web-shell ), and a full monorepo install plus prerequisite…

Test Plan(非阻断):client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 7 条(原文未翻译,列表见上方英文部分)。

[Critical] R2-1: This round's re-check rules this blocker still stands — the gate (MessageList.tsx:5450-5452) still reads exactly the three flags (!hasOlderHistory && !historyCapacityReached && !historyPaginationError), MessageList.tsx is unchanged since the finding, and the provider states this entry names are live at this head: on an anchor-less retention trim of a live-only session the fail-closed branch (DaemonSessionProvider.tsx ~857-874) latches hasMore=false while capacityReached and paginationError stay false, so the window is missing persisted older turns while all three gate flags read false; the edit affordance is then offered on the last user message and clicking it passes the window-local ordinal to editUserMessage (App.tsx:9439), which matches it against session-global snapshots and silently rewinds to an earlier turn, discarding everything after it — exactly the #10385 defect this gate exists to close. Round-2 probe on the real provider observed the window holding 1 of 3 user turns while all three flags read false; round-3 re-read the anchor-less branch at dfec5b5, and the author confirmed real and escalated for the provider-contract fix. Suggested fix: express window-completeness positively instead of as the complement of the three load flags — on an anchor-less eviction trim (and the truncated-replay initial-load states) have the provider latch a signal the gate can read (e.g. set capacityReached=true there, or add a fourth historyWindowIncomplete flag) — and add it to this gate and the render-callback dependency array. Fix acceptance criterion: a DaemonSessionProvider test asserting that after an anchor-less eviction trim the exposed history state still signals the window is incomplete, plus a case in this suite mounting with that signal set and asserting [data-testid=edit-u5] is null — removing either the provider latch or the gate check must turn them red.

— qwen3.8-max via Qwen Code /review (v0.22.3)

Comment thread packages/web-shell/client/components/MessageList.dom.test.tsx
…wind-turn-index

The base branch independently landed the hasOlderHistory edit gate; keep
this PR's superset that also fails closed on historyCapacityReached and
terminal historyPaginationError, along with the explanatory comment.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.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.

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R3 deferred clickEdit re-implements the file-scoped click helper (MessageList.dom.test.tsx:6531) — already reported (round-3 review 5057091635 deferral list); code unchanged since

Not explored to full depth (tool budget reached): "agent 4": none — no check was cut short..

Not reviewed: reverse audit — stopped before round 4 by the review time budget.

Test Plan (not a blocker): client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent 4"none — no check was cut short.

未审查:反向审计——评审时间预算不足,未能开始第 4 轮。

Test Plan(非阻断):client/components/MessageList.dom.test.tsxno such file or directory; components/MessageList.dom.test.tsxno such file or directory

— qwen3.8-max via Qwen Code /review (v0.22.3)

expect(c.querySelector('[data-testid="edit-u5"]')).not.toBeNull();
});

it('does not count user_shell echoes when numbering user turns', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-2: [fails-closed] This round's re-check rules this blocker still stands at 452ee22 — the base merge moved the echo path into sdk-typescript but changed nothing about the mechanism. editableUserTurn numbers every rendered role === 'user' message, but rewind snapshots are indexed by admitted daemon turns only. Locally echoed slash commands (/stats, /status, /about, /context) append kind:'user' blocks with no meta via echoLocalCommandIfIdle (App.tsx:7407) → store.appendLocalUserMessageappendLocalUserTranscriptMessage (sdk-typescript/src/daemon/ui/transcript.ts:145), and the adapter renders meta-less blocks as role:'user' (adapters/transcriptToMessages.ts:419), so they ARE counted while every gate flag correctly stays false on a complete window. The affordance then lands on the echo row itself, or passes an inflated ordinal for the next real prompt; editUserMessage (App.tsx:9491) finds no snapshot at that index and throws rewind.empty after the composer was replaced. One routine /stats breaks message editing for the rest of the live session view; the drift clears only on reload. A second entrance of the same root: a definitely-rejected send's optimistic block persists and is counted too, with no snapshot counterpart. The user_shell case pinned here covers !-style shell echoes only.

Witness (fresh DOM probe at 452ee22 in this review's scratch tree; it flips):

[..., echo('/stats')]        -> edit button rendered ON the echo row; onEditUserMessage(1, '/stats') vs snapshots {0}
[..., echo, a1, u2]          -> clickEdit('u2') passes (2, 'q') against snapshot indices {0,1}
same array minus the echo    -> clickEdit('u2') passes (1, 'q')  <- matches the daemon index
echo as role 'user_shell'    -> not counted, no button on the echo row

Suggested fix: tag local echoes at creation (e.g. meta: { source: 'local_command' } in the appendLocalUserMessage call chain) and skip such blocks in the editableUserTurn producer (both the numbering and the lastId candidate); applying the same marker to never-dispatched optimistic blocks also closes the rejected-send entrance. Failing closed whenever such a block is present is the minimal in-PR alternative.

Fix acceptance criterion: a DOM test mounting [userMsg('u1'), asstMsg('a1'), localEchoMsg('e1'), userMsg('u2')] with the local marker — assert no edit-e1 button and clickEdit(c, 'u2') calling onEditUserMessage with (1, 'q'); removing the producer skip must turn it red.

中文说明

本轮复查裁定该阻断在 452ee22 上依然成立 —— base 合并把回显路径挪进了 sdk-typescript,但机制本身没有任何变化。editableUserTurn 对每一个渲染出的 role === 'user' 消息编号,但回退快照只按守护进程真正受理的轮次索引。本地回显的 slash 命令(/stats/status/about/context)经 echoLocalCommandIfIdle(App.tsx:7407)→ store.appendLocalUserMessageappendLocalUserTranscriptMessage(sdk-typescript/src/daemon/ui/transcript.ts:145)追加不带 meta 的 kind:'user' 块,适配器把不带 meta 的块渲染为 role:'user'(adapters/transcriptToMessages.ts:419),因此它们会被计入编号,而窗口完整时所有门控标志都正确地保持为 false。于是编辑入口要么落在回显行本身上,要么为下一条真实消息传入偏大的序号;editUserMessage(App.tsx:9491)在该索引上找不到快照,在 composer 已被替换后抛出 rewind.empty。一次再平常不过的 /stats 就会让当前会话视图里的消息编辑在整个会话期间失效(只有重载才恢复)。同一根因的第二个入口:被彻底拒绝的发送留下的乐观块仍然存在且同样被计入编号,却没有对应快照。这里钉住的 user_shell 用例只覆盖 ! 形式的 shell 回显。

验证探针(本轮在 452ee22 上的独立临时树中运行的全新 DOM 探针,可翻转):含回显时编辑按钮渲染在回显行上、相对快照集 {0} 传入 (1, '/stats');回显之后的真实消息点击编辑传入 (2, 'q'),而快照索引只有 {0,1};去掉回显后传入 (1, 'q'),与守护进程索引一致;回显以 user_shell 角色渲染时不被计入、回显行上无按钮。

建议修复:在创建时给本地回显打标记(如在 appendLocalUserMessage 调用链中加 meta: { source: 'local_command' }),并在 editableUserTurn 生产者中跳过这类块(编号与 lastId 候选都跳过);同一标记用于从未分发的乐观块即可同时关闭被拒发送的入口。退一步的最小方案:只要存在这类块就失败关闭。

验收标准:新增 DOM 测试,以 [userMsg('u1'), asstMsg('a1'), localEchoMsg('e1'), userMsg('u2')](回显带本地标记)挂载,断言没有 edit-e1 按钮、clickEdit(c, 'u2')(1, 'q') 调用 onEditUserMessage;移除生产者中的跳过逻辑必须让该测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.3)

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.

Re-assertion of the open R1-2 thread (PRRT_kwDOPB-92c6dRhRj) — same file, same echo-skew finding, unchanged at this head. The author already responded there (3884091068) and the fix is deliberately human-gated: per-call-site tagging vs. an SDK-level flag spans >3 files and needs a design call before code lands. Tracking continues in the original thread; this duplicate stays open but unactioned until that decision is made.

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.

Same blocker as the original R1-2 thread (PRRT_kwDOPB-92c6dRhRj), where the author has already responded. The fix remains human-gated: tagging local-echo/rejected-send blocks and skipping them in the editableUserTurn producer is a >3-file design decision spanning the sdk-typescript transcript store (appendLocalUserTranscriptMessage), the client adapter, and the test suite. The branch is being actively revised by the PR team (head 14cfa47 merged today); keeping this open until that work lands. No code changes from our side this round.

// (or none at all). Fail closed (#10385).
!hasOlderHistory &&
!historyCapacityReached &&
!historyPaginationError &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-1: [certifies-falsely] This round's re-check rules this blocker still stands at 452ee22 — the gate still reads exactly the three flags, and the provider still has incomplete-window states none of them express. (a) Anchor-less live retention trim: DaemonSessionProvider.tsx:839-876 re-opens hasMore only when olderHistoryReachable (pagination feature advertised AND postTrimRetainedBytes < byteCap); when it is not, and in the uncomputable-anchor branch, nothing is latched while older turns were evicted. (b) Truncated replay on a daemon that does not advertise session_transcript_pagination (or has no firstPersistedRecordId): historyHasMore computes false (DaemonSessionProvider.tsx:1830-1837) and the injection (1855-1864) publishes hasMore:false, capacityReached:false, paginationError:false over a truncated window. In either state the gate opens, the affordance is offered on the last user message, and clicking it passes the window-local ordinal to editUserMessage, which matches it against session-global snapshots and silently rewinds to an earlier turn, discarding everything after it — exactly the #10385 defect this gate exists to close. This round's four finder agents independently rediscovered these entrances; they fold into this re-post under the original id.

Witness (probe driving the real DaemonSessionProvider at 452ee22):

[no-pagination-feature]  -> {"hasMore":false,"capacityReached":false,"paginationError":false}, window holds 1 of the session's user turns, truncation marker visible
[feature-but-no-anchor]  -> {"hasMore":false,"capacityReached":false,"paginationError":false}, same shape
[feature-and-anchor]     -> {"hasMore":true,...}  <- flips, proving the probe discriminates

Suggested fix: express window-completeness positively instead of as the complement of the three load flags — on an anchor-less eviction trim and the truncated-replay injection states have the provider latch a signal the gate can read (e.g. set capacityReached=true there, or add a fourth historyWindowIncomplete flag), and add it to this gate and the render-callback dependency array. The fix must not re-open the load-older affordance anchor-less: DaemonSessionProvider.tsx:895-898if (!paginationSupported || !anchored) { return; } — and the fail-closed eviction branch (862-876) deliberately drops both anchors. Fix acceptance criterion: a DaemonSessionProvider test asserting that after an anchor-less eviction trim or a truncated replay the exposed history state still signals the window is incomplete, plus a case in this suite mounting with that signal set and asserting [data-testid=edit-u5] is null; removing either the provider latch or the gate check must turn them red.

中文说明

本轮复查裁定该阻断在 452ee22 上依然成立 —— 门控仍然只读取这三个标志,而 provider 仍存在这三个标志都无法表达的不完整窗口状态。(a) 无锚点的在线保留裁剪:DaemonSessionProvider.tsx:839-876 仅在 olderHistoryReachable(通告了分页功能且 postTrimRetainedBytes < byteCap)时才重新打开 hasMore;不满足时、以及重锚点不可计算分支中,较早轮次已被驱逐却没有置位任何标志。(b) 未通告 session_transcript_pagination(或没有 firstPersistedRecordId)的守护进程上的截断重放:historyHasMore 计算为 false(DaemonSessionProvider.tsx:1830-1837),注入(1855-1864)在截断的窗口上发布 hasMore:false, capacityReached:false, paginationError:false。在这两种状态下门控都会打开,编辑入口出现在最后一条用户消息上,点击后把窗口内局部序号传给 editUserMessage,与全局快照匹配后静默回退到更早的轮次,丢弃其后的一切 —— 正是本门控要关闭的 #10385 缺陷。本轮四个探查代理独立重新发现了这些入口;现以原编号并入本条重发。

验证探针(在 452ee22 上驱动真实 DaemonSessionProvider):[无分页功能] 与 [有功能但无锚点] 两个分支均在窗口只含会话 1 个用户轮、截断标记可见的情况下发布 {"hasMore":false,"capacityReached":false,"paginationError":false};[有功能且有锚点] 分支翻转为 {"hasMore":true,...},证明探针具有区分力。

建议修复:把窗口完整性表达为正向信号,而不是三个加载标志的补集 —— 在无锚点驱逐裁剪与截断重放注入状态中,让 provider 置位一个门控可读的信号(如在那里设 capacityReached=true,或新增第四个 historyWindowIncomplete 标志),并把它加入本门控与渲染回调依赖数组。修复不得在无锚点时重新打开加载更早内容的入口:DaemonSessionProvider.tsx:895-898 —— if (!paginationSupported || !anchored) { return; } —— 失败关闭的驱逐分支(862-876)有意丢弃两个锚点。验收标准:DaemonSessionProvider 测试断言无锚点驱逐裁剪或截断重放后暴露的历史状态仍发出窗口不完整信号,并在本套件中以该信号置位挂载、断言 [data-testid=edit-u5] 为 null;移除 provider 置位或门控检查任一项都必须让测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.3)

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.

Re-assertion of the open R2-1 thread (PRRT_kwDOPB-92c6dUzxW) — same line (MessageList.tsx:5452), same finding: the gate reads the three negated flags as a complete window-completeness signal while the provider has incomplete-window states none of them express. The author already responded there (3885451398); the fix is human-gated on the provider-contract decision (positive window-completeness signal vs. a fourth flag, ~5 files). Tracking continues in the original thread; this duplicate stays open until that decision is made.

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.

Same blocker as the original R2-1 thread (PRRT_kwDOPB-92c6dUzxW), where the author has already responded. The fix remains human-gated: expressing window-completeness positively (a provider latch on anchor-less eviction / truncated replay, or a fourth flag) is a provider-contract design decision in DaemonSessionProvider that the PR team owns. The branch is being actively revised by the PR team (head 14cfa47 merged today); keeping this open until that decision lands. No code changes from our side this round.

// hasMore=false and merges nothing), the window-local
// user-turn ordinal is not the session-global turn index,
// so offering edit here would rewind to the wrong snapshot
// (or none at all). Fail closed (#10385).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R4-1: [fails-closed] The gate's certified invariant — window-local user-turn ordinal equals the session-global rewind-snapshot index — breaks even on a COMPLETE window once a session exceeds 100 admitted user turns. FileHistoryService caps snapshots at MAX_SNAPSHOTS = 100 (packages/core/src/services/fileHistoryService.ts:103) and evicts the oldest (816-820), while the rewind_snapshots RPC reports turnIndex: idx — the position in the evicted array (packages/cli/src/acp-integration/acpAgent.ts:8543). getRewindableUserTurnCount is uncapped (Session.ts:3981-3994), so above the cap the reported set stays 0..99 while the transcript ordinal keeps counting. A session with 101 admitted user turns fits entirely in the window (DEFAULT_MAX_BLOCKS = 1000), all three flags are false, and this gate offers edit — but the last message's ordinal (100) never matches the reported 0..99 set, so every click throws rewind.empty after the composer was replaced: the affordance is offered yet deterministically dead in exactly the state this comment certifies as safe. The same idx < rewindableTurnCount filter also drops the newest snapshots when context compression shrinks the API-history user-turn count, so compressed sessions fail identically below 100 turns (verified in mechanism; over-rewind is structurally excluded — the probe confirmed every surviving entry still maps to its correct turn).

Witness (probe running the real FileHistoryService at 452ee22):

ARM A (N=100): reportedTurnIndexes 0..99, lastOrdinal 99 -> lookup resolves, edit works
ARM B (N=101): firstPromptId rebased to turn 2, lastOrdinal 100 -> lookup 'rewind.empty', edit dead
ARM C (50 snapshots, rewindableTurnCount=30): only idx 0..29 reported -> 'rewind.empty', overRewindPossible: false

Deterministic split at exactly MAX_SNAPSHOTS + 1.

Suggested fix: stop deriving the rewind target from a positional index — resolve it by promptId/record id (the daemon already resolves promptId → snapshot, acpAgent.ts:11366-11382), or have the RPC report eviction-aware global ordinals; if the fix must stay inside this PR's client-side gate, fail closed when the window's user-turn count exceeds the resolvable snapshot count (≤ MAX_SNAPSHOTS). The fix must respect MAX_SNAPSHOTS = 100 (fileHistoryService.ts:103) and the positional turnIndex: idx contract (acpAgent.ts:8543) that the rewind handler's promptId → array-position resolution (acpAgent.ts:11382) relies on. Fix acceptance criterion: a case in this describe mounting a complete window of 101 user messages and asserting the edit button is NOT rendered (or that the handler receives a snapshot-resolvable id, if the promptId fix is chosen); removing the guard must turn it red.

中文说明

门控所认证的不变量 —— 窗口内用户轮序号等于会话全局回退快照索引 —— 在会话超过 100 个受理轮次后,即使窗口完整也会失效。FileHistoryService 把快照上限设为 MAX_SNAPSHOTS = 100(packages/core/src/services/fileHistoryService.ts:103)并驱逐最旧的(816-820),而 rewind_snapshots RPC 报告的是 turnIndex: idx —— 被驱逐后数组中的位置(packages/cli/src/acp-integration/acpAgent.ts:8543)。getRewindableUserTurnCount 不设上限(Session.ts:3981-3994),因此超过上限后上报集合仍是 0..99,而转录序号继续增长。101 个受理轮的会话完全放得进窗口(DEFAULT_MAX_BLOCKS = 1000),三个标志全为 false,门控放行编辑 —— 但最后一条消息的序号(100)永远匹配不上 0..99 的上报集合,每次点击都在 composer 已被替换后抛出 rewind.empty:入口被提供、在这段注释认证为安全的状态里却确定性地失效。同一 idx < rewindableTurnCount 过滤器也会在上下文压缩减少 API 历史用户轮数时丢掉最新的快照,因此压缩后的会话在 100 轮以下同样失效(机制已验证;过度回退在结构上被排除 —— 探针确认所有幸存条目仍映射到正确的轮次)。

验证探针(在 452ee22 上运行真实 FileHistoryService):ARM A(N=100)上报 0..99、最后序号 99 → 查找到,编辑可用;ARM B(N=101)firstPromptId 重定基到第 2 轮、最后序号 100 → 'rewind.empty',编辑失效;ARM C(50 个快照、rewindableTurnCount=30)只上报 0..29 → 'rewind.empty',overRewindPossible: false。分裂点恰在 MAX_SNAPSHOTS + 1

建议修复:不要再从位置索引推导回退目标 —— 按 promptId/记录 id 解析(守护进程已能把 promptId 解析到快照,acpAgent.ts:11366-11382),或让 RPC 上报感知驱逐的全局序号;若修复必须留在本 PR 的客户端门控内,则在窗口用户轮数超过可解析快照数(≤ MAX_SNAPSHOTS)时失败关闭。修复必须尊重 MAX_SNAPSHOTS = 100(fileHistoryService.ts:103)与位置式 turnIndex: idx 契约(acpAgent.ts:8543)—— 回退处理器的 promptId → 数组位置解析(acpAgent.ts:11382)依赖它。验收标准:在本 describe 中新增以 101 条用户消息的完整窗口挂载的用例,断言编辑按钮不渲染(若选择 promptId 修复,则断言处理器收到可解析的快照 id);移除该守卫必须让测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.3)

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.

Verified as a real, distinct angle at 452ee22 — checked the anchors at this head:

  • MAX_SNAPSHOTS = 100 with oldest-first eviction — packages/core/src/services/fileHistoryService.ts:103, 816-820
  • rewind_snapshots reports the positional turnIndex: idx filtered by idx < rewindableTurnCount — packages/cli/src/acp-integration/acpAgent.ts:8528-8543
  • getRewindableUserTurnCount() is uncapped and counts API-history user turns — packages/cli/src/acp-integration/session/Session.ts:3981-3994 (note: lives in cli/acp-integration/session, not core)

So the gate's certified invariant (window-local ordinal == session-global snapshot index) breaks on a COMPLETE window past 100 admitted turns, and the rewindableTurnCount filter reproduces it below 100 under context compression. Distinct from R2-1: the window is complete and all three flags are correctly false here, so R2-1's positive completeness signal would not close this entrance. Leaving open — the durable fix (promptId-keyed snapshot resolution, which the daemon already supports for rewind execution, or eviction-aware ordinals) crosses core/cli and needs a design call rather than a client-side gate tweak in this PR.

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.

Verified against head 14cfa47 — the mechanism holds at every cited point:

  • MAX_SNAPSHOTS = 100 with oldest-eviction: packages/core/src/services/fileHistoryService.ts:103,816-820
  • rewind_snapshots reports positional turnIndex: idx over the post-eviction array, filtered by idx < rewindableTurnCount: packages/cli/src/acp-integration/acpAgent.ts:8528,8538-8543
  • getRewindableUserTurnCount is uncapped and derived from API history (so compression shrinks it): packages/cli/src/acp-integration/session/Session.ts:3990
  • the gate reads only the three window flags, no snapshot-cap signal: packages/web-shell/client/components/MessageList.tsx:5450-5452
  • editUserMessage matches entry.turnIndex === turnIndex and throws rewind.empty after the composer is replaced: packages/web-shell/client/App.tsx:9545-9548
  • a 101-turn session fits one complete window: DEFAULT_MAX_BLOCKS = 1_000, packages/sdk-typescript/src/daemon/ui/transcript.ts:31

Classification: this is the same invariant class as R1-2/R2-1 — the gate's certified invariant (window-local user-turn ordinal == session-global rewind-snapshot index) is broken by server-side state (snapshot-cap eviction, compression-shrunk turn count) that none of the gate's client-side flags can express. No <=3-file fix exists: id-based resolution or eviction-aware ordinals changes the rewind-snapshot RPC contract across acpAgent.ts / Session.ts / fileHistoryService.ts plus the client gate, and a client-only fail-closed is impossible because the client cannot know the resolvable snapshot count without a provider signal. Folding into the same human-gated provider-contract design decision tracked in the original R2-1 thread (PRRT_kwDOPB-92c6dUzxW). No code changes this round; the branch is being actively revised by the PR team.

Comment thread package-lock.json
yiliang114 and others added 4 commits August 29, 2026 22:02
…wind-turn-index

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…wind-turn-index

Keep historyCapacityReached/historyPaginationError in the display-item
callback deps: the #10385 fail-closed edit condition reads both inside
the callback body.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…wind-turn-index

Keeps the R1-1 historyPaginationError fail-closed gate term and its
useCallback dependency entry where the base sync (8a51002, 3cf5e5f)
independently landed the historyCapacityReached term; both terms are
preserved in the merged gate.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Base sync + conflict resolution (patrol closeout):

  • Merged codex/vscode-web-shell-cutover tip (5d7fa3d) to clear the CONFLICTING state; single conflict in packages/web-shell/client/components/MessageList.tsx where the base sync (8a51002, 3cf5e5f) independently landed the historyCapacityReached gate term. Resolution keeps both that term and this PR's R1-1 historyPaginationError term in the fail-closed gate and in the render-callback deps (66773a9).
  • Verified at the merged head: MessageList.dom.test.tsx 166/166 pass (includes the fix(web-shell): message edit passes window-local turn index to session-global rewind snapshots #10385 edit-affordance gate and pagination-error tests), web-shell typecheck clean.
  • PR is MERGEABLE again. The 5 open review threads (R1-2 echo-skew, R2-1 provider-contract, R4-1 snapshot-cap, plus two re-posts) remain human-gated design decisions — no change from the author's earlier replies; this merge touches none of those mechanisms.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Superseded — closing.

The same-design fix is already on main: MessageList.tsx gates onEditUserMessage behind !hasOlderHistory && !historyCapacityReached and resolves the turn index via editableUserTurn.turnIndexById (message id → global index), which is the global-index closure this issue's suggested scope asked for. The cutover branch picked the fix up in a later round and it landed through the squash merge fe34a5c of #9811, so this branch is no longer needed. The issue (#10385) has been closed with the same evidence.

@yiliang114 yiliang114 closed this Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants