-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(web-shell): message edit fails closed while the transcript window is incomplete #10419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9a65763
14433ff
47125da
dfec5b5
452ee22
14cfa47
fb97864
8430487
66773a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,7 @@ vi.mock('./MessageItem', async () => { | |
| assistantTurnFooterInfo, | ||
| sendFailed, | ||
| onRetrySend, | ||
| onEditUserMessage, | ||
| }: { | ||
| message: Message; | ||
| showAssistantActions?: boolean; | ||
|
|
@@ -63,6 +64,7 @@ vi.mock('./MessageItem', async () => { | |
| assistantTurnFooterInfo?: WebShellAssistantTurnFooterRenderInfo; | ||
| sendFailed?: boolean; | ||
| onRetrySend?: () => void; | ||
| onEditUserMessage?: () => void; | ||
| }) => { | ||
| if (message.role === 'tool_group') { | ||
| messageItemTestState.toolArrays.push(message.tools); | ||
|
|
@@ -107,6 +109,17 @@ vi.mock('./MessageItem', async () => { | |
| 'data-testid': `disclosure-${message.id}`, | ||
| }) | ||
| : null, | ||
| onEditUserMessage | ||
| ? React.createElement( | ||
| 'button', | ||
| { | ||
| 'data-testid': `edit-${message.id}`, | ||
| onClick: onEditUserMessage, | ||
| type: 'button', | ||
| }, | ||
| 'edit', | ||
| ) | ||
| : null, | ||
| showAssistantBranch | ||
| ? React.createElement('button', { | ||
| 'data-testid': `branch-${message.id}`, | ||
|
|
@@ -353,6 +366,7 @@ function mount( | |
| pendingApproval?: PermissionRequest | null; | ||
| failedPromptMessageId?: string; | ||
| onRetryFailedPrompt?: () => void; | ||
| onEditUserMessage?: (targetTurnIndex: number, content: string) => void; | ||
| } = {}, | ||
| ): HTMLElement { | ||
| const container = document.createElement('div'); | ||
|
|
@@ -392,6 +406,7 @@ function mount( | |
| onCanScrollToBottomChange={opts.onCanScrollToBottomChange} | ||
| failedPromptMessageId={opts.failedPromptMessageId} | ||
| onRetryFailedPrompt={opts.onRetryFailedPrompt} | ||
| onEditUserMessage={opts.onEditUserMessage} | ||
| /> | ||
| </TranscriptRenderModeProvider> | ||
| </CompactModeContext.Provider> | ||
|
|
@@ -416,8 +431,11 @@ function rerenderMessages( | |
| catchingUp?: boolean; | ||
| isResponding?: boolean; | ||
| hasOlderHistory?: boolean; | ||
| historyCapacityReached?: boolean; | ||
| historyPaginationError?: boolean; | ||
| onLoadOlderHistory?: (options?: { force?: boolean }) => Promise<void>; | ||
| sessionKey?: string; | ||
| onEditUserMessage?: (targetTurnIndex: number, content: string) => void; | ||
| } = {}, | ||
| ): void { | ||
| const entry = mounted.find((item) => item.container === container); | ||
|
|
@@ -435,8 +453,11 @@ function rerenderMessages( | |
| catchingUp={opts.catchingUp} | ||
| isResponding={opts.isResponding} | ||
| hasOlderHistory={opts.hasOlderHistory} | ||
| historyCapacityReached={opts.historyCapacityReached} | ||
| historyPaginationError={opts.historyPaginationError} | ||
| onLoadOlderHistory={opts.onLoadOlderHistory} | ||
| sessionKey={opts.sessionKey} | ||
| onEditUserMessage={opts.onEditUserMessage} | ||
| /> | ||
| </TranscriptRenderModeProvider> | ||
| </CompactModeContext.Provider> | ||
|
|
@@ -6505,3 +6526,130 @@ describe('MessageList — turn collapse (DOM)', () => { | |
| expect(parallelAgentsSummary(c)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('user message edit affordance (issue #10385)', () => { | ||
| function clickEdit(container: HTMLElement, messageId: string): void { | ||
| const button = container.querySelector(`[data-testid="edit-${messageId}"]`); | ||
| expect(button).not.toBeNull(); | ||
| act(() => { | ||
| (button as HTMLButtonElement).dispatchEvent( | ||
| new MouseEvent('click', { bubbles: true }), | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| it('does not offer editing while older history is unloaded', () => { | ||
| const onEditUserMessage = vi.fn(); | ||
| // Session-global history has more user turns than the loaded window: | ||
| // only the last two user messages are rendered. The window-local ordinal | ||
| // of the last message (1) is not its session-global turn index, so the | ||
| // edit affordance must not be offered while the window is incomplete. | ||
| const c = mount([userMsg('u4'), asstMsg('a4'), userMsg('u5')], undefined, { | ||
| hasOlderHistory: true, | ||
| onLoadOlderHistory: async () => {}, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).toBeNull(); | ||
| expect(c.querySelector('[data-testid="edit-u4"]')).toBeNull(); | ||
| expect(onEditUserMessage).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('does not offer editing when older history was dropped for window capacity', () => { | ||
| const onEditUserMessage = vi.fn(); | ||
| const c = mount([userMsg('u4'), asstMsg('a4'), userMsg('u5')], undefined, { | ||
| historyCapacityReached: true, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).toBeNull(); | ||
| expect(onEditUserMessage).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('does not offer editing while an older-history page terminally failed to load', () => { | ||
| const onEditUserMessage = vi.fn(); | ||
| // A non-retryable load-older failure latches the provider at | ||
| // hasMore=false, capacityReached=false, paginationError=true while the | ||
| // window is still missing older turns, so the window-local ordinal of | ||
| // the last message (1) is not its session-global turn index either. | ||
| const c = mount([userMsg('u4'), asstMsg('a4'), userMsg('u5')], undefined, { | ||
| historyPaginationError: true, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).toBeNull(); | ||
| expect(onEditUserMessage).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('offers editing only for the last user message when the window is complete', () => { | ||
| const onEditUserMessage = vi.fn(); | ||
| const c = mount([userMsg('u1'), asstMsg('a1'), userMsg('u2')], undefined, { | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u1"]')).toBeNull(); | ||
| clickEdit(c, 'u2'); | ||
| expect(onEditUserMessage).toHaveBeenCalledTimes(1); | ||
| expect(onEditUserMessage).toHaveBeenCalledWith(1, 'q'); | ||
| }); | ||
|
|
||
| it('re-offers editing when hasOlderHistory flips without a message identity change', () => { | ||
| const onEditUserMessage = vi.fn(); | ||
| const messages = [userMsg('u4'), asstMsg('a4'), userMsg('u5')]; | ||
| const c = mount(messages, undefined, { | ||
| hasOlderHistory: true, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).toBeNull(); | ||
| // A load-older request resolving with zero additional turns flips the | ||
| // flag while the messages array keeps its identity; the render callback | ||
| // must not hold the stale flag value. | ||
| rerenderMessages(c, messages, { | ||
| hasOlderHistory: false, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).not.toBeNull(); | ||
| clickEdit(c, 'u5'); | ||
| expect(onEditUserMessage).toHaveBeenCalledWith(1, 'q'); | ||
| }); | ||
|
|
||
| it('re-offers editing when historyCapacityReached flips without a message identity change', () => { | ||
| const onEditUserMessage = vi.fn(); | ||
| const messages = [userMsg('u4'), asstMsg('a4'), userMsg('u5')]; | ||
| const c = mount(messages, undefined, { | ||
| historyCapacityReached: true, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).toBeNull(); | ||
| rerenderMessages(c, messages, { | ||
| historyCapacityReached: false, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('re-offers editing when historyPaginationError flips without a message identity change', () => { | ||
| const onEditUserMessage = vi.fn(); | ||
| const messages = [userMsg('u4'), asstMsg('a4'), userMsg('u5')]; | ||
| const c = mount(messages, undefined, { | ||
| historyPaginationError: true, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).toBeNull(); | ||
| // A successful retry of the failed load-older page clears the flag | ||
| // while the messages array keeps its identity; the render callback | ||
| // must not hold the stale flag value. | ||
| rerenderMessages(c, messages, { | ||
| historyPaginationError: false, | ||
| onEditUserMessage, | ||
| }); | ||
| expect(c.querySelector('[data-testid="edit-u5"]')).not.toBeNull(); | ||
| }); | ||
|
|
||
| it('does not count user_shell echoes when numbering user turns', () => { | ||
|
yiliang114 marked this conversation as resolved.
yiliang114 marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Witness (fresh DOM probe at 452ee22 in this review's scratch tree; it flips): Suggested fix: tag local echoes at creation (e.g. Fix acceptance criterion: a DOM test mounting 中文说明本轮复查裁定该阻断在 452ee22 上依然成立 —— base 合并把回显路径挪进了 sdk-typescript,但机制本身没有任何变化。 验证探针(本轮在 452ee22 上的独立临时树中运行的全新 DOM 探针,可翻转):含回显时编辑按钮渲染在回显行上、相对快照集 {0} 传入 (1, '/stats');回显之后的真实消息点击编辑传入 (2, 'q'),而快照索引只有 {0,1};去掉回显后传入 (1, 'q'),与守护进程索引一致;回显以 建议修复:在创建时给本地回显打标记(如在 验收标准:新增 DOM 测试,以 — qwen3.8-max via Qwen Code /review (v0.22.3)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| const onEditUserMessage = vi.fn(); | ||
| const c = mount( | ||
| [userMsg('u1'), userShellMsg('s1'), asstMsg('a1'), userMsg('u2')], | ||
| undefined, | ||
| { onEditUserMessage }, | ||
| ); | ||
| clickEdit(c, 'u2'); | ||
| expect(onEditUserMessage).toHaveBeenCalledWith(1, 'q'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5437,8 +5437,19 @@ export const MessageList = memo( | |
| onEditUserMessage={ | ||
| onEditUserMessage && | ||
| !isResponding && | ||
| // Rewind snapshots are indexed session-globally, but the | ||
| // rendered transcript is a capped/paginated window. While | ||
| // older history is still unloaded (hasOlderHistory), was | ||
| // dropped for window capacity (historyCapacityReached), or | ||
| // the load-older page failed terminally | ||
| // (historyPaginationError — the provider then latches | ||
| // 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). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Witness (probe running the real FileHistoryService at 452ee22): Deterministic split at exactly 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 中文说明门控所认证的不变量 —— 窗口内用户轮序号等于会话全局回退快照索引 —— 在会话超过 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。分裂点恰在 建议修复:不要再从位置索引推导回退目标 —— 按 promptId/记录 id 解析(守护进程已能把 promptId 解析到快照,acpAgent.ts:11366-11382),或让 RPC 上报感知驱逐的全局序号;若修复必须留在本 PR 的客户端门控内,则在窗口用户轮数超过可解析快照数(≤ MAX_SNAPSHOTS)时失败关闭。修复必须尊重 — qwen3.8-max via Qwen Code /review (v0.22.3)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
So the gate's certified invariant (window-local ordinal == session-global snapshot index) breaks on a COMPLETE window past 100 admitted turns, and the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified against head 14cfa47 — the mechanism holds at every cited point:
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 |
||
| !hasOlderHistory && | ||
| !historyCapacityReached && | ||
|
yiliang114 marked this conversation as resolved.
yiliang114 marked this conversation as resolved.
|
||
| !historyPaginationError && | ||
|
Comment on lines
+5450
to
+5452
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Witness (real 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 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 Fix acceptance criterion: a 中文说明这里取反的三个标志被当作"窗口内局部用户轮序号等于会话全局回退快照索引"的完备信号,但历史提供器存在这三者都无法表达的不完整窗口状态。通过运行真实提供器证实:recordId 只在重放持久化转录时打标,实时事件流上从不打标(bridge.ts ~6660),因此当一个从头活跃的会话被客户端保留策略裁剪掉最旧的块时, 验证证据(真实 同根因还有两个兄弟状态:重开字节门(~836-856)仅在 建议修复:用正向信号表达窗口完整性,而不是三个加载标志的取反 —— 在无锚点驱逐裁剪(以及截断重放的初始加载状态)时,让提供器置位一个门控可读的信号(如在那里置 验收标准: — qwen3.8-max via Qwen Code /review (v0.22.2)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Design questions blocking a worker-pass fix:
Leaving unresolved for maintainer decision.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Witness (probe driving the real DaemonSessionProvider at 452ee22): 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 中文说明本轮复查裁定该阻断在 452ee22 上依然成立 —— 门控仍然只读取这三个标志,而 provider 仍存在这三个标志都无法表达的不完整窗口状态。(a) 无锚点的在线保留裁剪: 验证探针(在 452ee22 上驱动真实 DaemonSessionProvider):[无分页功能] 与 [有功能但无锚点] 两个分支均在窗口只含会话 1 个用户轮、截断标记可见的情况下发布 {"hasMore":false,"capacityReached":false,"paginationError":false};[有功能且有锚点] 分支翻转为 {"hasMore":true,...},证明探针具有区分力。 建议修复:把窗口完整性表达为正向信号,而不是三个加载标志的补集 —— 在无锚点驱逐裁剪与截断重放注入状态中,让 provider 置位一个门控可读的信号(如在那里设 — qwen3.8-max via Qwen Code /review (v0.22.3)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| displayItem.message.role === 'user' && | ||
| editableUserContent !== undefined && | ||
| displayItem.message.id === editableUserTurn.lastId | ||
|
|
@@ -5516,6 +5527,7 @@ export const MessageList = memo( | |
| editableUserTurn, | ||
| hasOlderHistory, | ||
| historyCapacityReached, | ||
|
yiliang114 marked this conversation as resolved.
|
||
| historyPaginationError, | ||
|
yiliang114 marked this conversation as resolved.
|
||
| generateContent, | ||
| headerOffset, | ||
| visibleItems, | ||
|
|
||
There was a problem hiding this comment.
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.
editableUserTurnnumbers every renderedrole === 'user'message, but rewind snapshots are indexed by admitted daemon turns only (Session.tsmakeSnapshot deliberately skips locally handled commands). Locally echoed slash commands —/stats,/status,/about,/context; none are hidden byVSCODE_HIDDEN_SLASH_COMMANDS— appendkind: 'user'blocks with no meta viaechoLocalCommandIfIdle(App.tsx:10498) →appendLocalUserTranscriptMessage, and the adapter renders themrole: '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;editUserMessagefinds no snapshot at that index →rewind.emptyafter the composer was replaced (standalone), orcomposer.editExpiredat re-submit with subsequent sends kept cancelling until the editing state is cleared (the shipped VS Code flow, EmbeddedApp.tsx:1371-1414). One routine/statsbreaks 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. Theuser_shellcase 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):
Suggested fix: tag local echoes at creation (e.g.
meta: { source: 'local_command' }inappendLocalUserTranscriptMessage— the adapter already liftsmeta.source) and skip such blocks in theeditableUserTurnproducer (both the numbering and thelastIdcandidate); 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 noedit-e1button andclickEdit(c, 'u2')callingonEditUserMessagewith(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)
There was a problem hiding this comment.
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:
Session.ts(~5232) placesmakeSnapshotafter the slash-command/hook early returns, with a comment stating locally handled commands must not create phantom snapshots that desync the snapshot index.echoLocalCommandIfIdle(App.tsx:7356; call sites 10498/10514 for/stats,/status,/about) →store.appendLocalUserMessage(text)with no meta → adapter rendersrole: 'user'(transcriptToMessages.ts:470-483 liftsmeta.sourcewhen present) → counted byeditableUserTurn(MessageList.tsx:2892, counts everyrole === 'user')./stats//status//about//contextare absent fromVSCODE_HIDDEN_SLASH_COMMANDS(EmbeddedApp.tsx:83-103).user.text.deltaever merges into it.Design questions blocking a worker-pass fix:
/goal~780,/context~1054 — the latter is a toolbar button, not a rare path), theeditableUserTurnproducer in MessageList.tsx, plus tests.appendTextDeltamerges 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).Leaving unresolved for maintainer decision.