Repository navigation
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 4 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.
Resolved
yiliang114 marked this conversation as resolved.
Resolved
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,6 +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
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.
Resolved
yiliang114 marked this conversation as resolved.
Resolved
|
||
| !historyPaginationError && | ||
|
Comment on lines
+5450
to
+5452
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
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 | ||
|
|
@@ -5512,6 +5525,9 @@ export const MessageList = memo( | |
| onInsightReportOpen, | ||
| onEditUserMessage, | ||
| editableUserTurn, | ||
| hasOlderHistory, | ||
| historyCapacityReached, | ||
|
yiliang114 marked this conversation as resolved.
Resolved
|
||
| historyPaginationError, | ||
|
yiliang114 marked this conversation as resolved.
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.
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.