From 7369191192b85c96326fe7ca6c3b47f667e750c9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 13:31:10 +0200 Subject: [PATCH 1/8] fix(coding-agent): single-source interactive queue state --- .../.changes/snimu-tui-queue-single-source.md | 1 + .../src/modes/interactive/interactive-mode.ts | 128 ++++---------- .../src/modes/interactive/queue-selection.ts | 21 --- .../test/interactive-mode-ctrl-c.test.ts | 10 +- .../interactive-mode-feature-hints.test.ts | 6 +- .../interactive-mode-prompt-stash.test.ts | 13 +- .../test/interactive-queue-edit.test.ts | 162 +++++------------- .../coding-agent/test/queue-selection.test.ts | 22 --- .../regressions/4741-hint-placement.test.ts | 10 +- 9 files changed, 103 insertions(+), 270 deletions(-) create mode 100644 packages/coding-agent/.changes/snimu-tui-queue-single-source.md diff --git a/packages/coding-agent/.changes/snimu-tui-queue-single-source.md b/packages/coding-agent/.changes/snimu-tui-queue-single-source.md new file mode 100644 index 00000000000..09067e72b84 --- /dev/null +++ b/packages/coding-agent/.changes/snimu-tui-queue-single-source.md @@ -0,0 +1 @@ +- Fixed queued-message editing so duplicate prompts always target the selected queue entry. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index db5abdb5c65..8d4f4f34c96 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1017,8 +1017,6 @@ export class InteractiveMode { private retryCountdown: CountdownTimer | undefined = undefined; private traceUploadAllAbortController: AbortController | undefined = undefined; - // Session-owned queued messages mirrored from connection events. - private connectionQueue: AgentConnectionQueueState = { steering: [], followUp: [] }; private readonly queueSelection = new QueueSelection(); private isApplyingQueueSelectionText = false; private queueMutationChain: Promise = Promise.resolve(); @@ -2510,24 +2508,28 @@ export class InteractiveMode { } } + private getConnectionQueue(): AgentConnectionQueueState { + return { + steering: [...(this.connectionState?.sessionActions.steering ?? [])], + followUp: [...(this.connectionState?.sessionActions.followUps ?? [])], + }; + } + private async refreshConnectionQueue(): Promise { this.replaceConnectionQueue(await this.agentConnection.getQueue()); } private replaceConnectionQueue(queue: AgentConnectionQueueState): void { - this.connectionQueue = { - steering: [...queue.steering], - followUp: [...queue.followUp], - }; - const dropped = this.queueSelection.sync(this.connectionQueue); - if (dropped !== undefined) { - const editorText = this.editor.getText(); - if (editorText === dropped) { - this.setEditorTextFromQueueSelection(this.queueSelection.reset()); - } else if (!this.pendingQueueEdit) { - this.queueSelection.replaceDraft(editorText); - } - } + const sessionActions = this.connectionState?.sessionActions; + if (!sessionActions) return; + this.patchConnectionState({ + sessionActions: { + ...sessionActions, + queuedCount: queue.steering.length + queue.followUp.length, + steering: [...queue.steering], + followUps: [...queue.followUp], + }, + }); this.updatePendingMessagesDisplay(); } @@ -2829,7 +2831,6 @@ export class InteractiveMode { this.shortcutGuideContainer.clear(); this.pendingMessagesContainer.clear(); this.queuedMessagesContainer.clear(); - this.connectionQueue = { steering: [], followUp: [] }; this.pendingQueueEdit = undefined; // The selection and its stashed draft belong to the previous session; // every editor draft is cleared below, so discard rather than restore. @@ -4420,7 +4421,8 @@ export class InteractiveMode { for (const entry of this.editor.getHistory?.() ?? []) { add(entry); } - for (const msg of [...this.connectionQueue.steering, ...this.connectionQueue.followUp]) { + const queue = this.getConnectionQueue(); + for (const msg of [...queue.steering, ...queue.followUp]) { add(msg); } return ids; @@ -5363,10 +5365,7 @@ export class InteractiveMode { break; case "session_action_update": { - this.replaceConnectionQueue({ - steering: [...event.actions.steering], - followUp: [...event.actions.followUps], - }); + this.updatePendingMessagesDisplay(); this.ui.requestRender(); break; } @@ -6992,7 +6991,7 @@ export class InteractiveMode { private browseQueueSelection(direction: -1 | 1): void { if (this.pendingQueueEdit) return; - const text = this.queueSelection.move(this.connectionQueue, this.editor.getText(), direction); + const text = this.queueSelection.move(this.getConnectionQueue(), this.editor.getText(), direction); if (text === undefined) return; this.setEditorTextFromQueueSelection(text); this.ui.requestRender(); @@ -7015,40 +7014,16 @@ export class InteractiveMode { const sessionGeneration = this.sessionEventGeneration; void this.enqueueQueueMutation(async () => { if (sessionGeneration !== this.sessionEventGeneration) return; - const lane = this.connectionQueue[submittedSelection.lane]; - const resolvedIndex = - lane[submittedSelection.index] === submittedSelection.text - ? submittedSelection.index - : lane.indexOf(submittedSelection.text); - if (resolvedIndex < 0) { - this.showStatus("Queue changed; reorder not applied"); - return; - } - const selected = { ...submittedSelection, index: resolvedIndex }; - const queueBefore = this.connectionQueue; - const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { - type: "move", - direction, - }); + const status = await this.agentConnection.mutateQueuedMessage( + submittedSelection.lane, + submittedSelection.index, + submittedSelection.text, + { type: "move", direction }, + ); if (sessionGeneration !== this.sessionEventGeneration) return; if (status === "applied") { - // The queue event for this mutation can land before or after the - // response. Patch the mirror only when no event has replaced it - // meanwhile (events always assign a fresh object); patching an - // already-updated mirror would apply the mutation twice. - const lane = this.connectionQueue[selected.lane]; - const target = selected.index + direction; - if ( - this.connectionQueue === queueBefore && - lane[selected.index] === selected.text && - target >= 0 && - target < lane.length - ) { - [lane[selected.index], lane[target]] = [lane[target] as string, selected.text]; - this.queueSelection.sync(this.connectionQueue); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); - } + await this.refreshConnectionQueue(); + this.ui.requestRender(); } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; reorder not applied"); }).catch((error) => { @@ -7096,33 +7071,12 @@ export class InteractiveMode { }; return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; - // Earlier serialized moves may have changed the selected item's index. - const lane = this.connectionQueue[submittedSelection.lane]; - const resolvedIndex = - lane[submittedSelection.index] === submittedSelection.text - ? submittedSelection.index - : lane.indexOf(submittedSelection.text); - if (resolvedIndex < 0) { - this.queueSelection.sync(this.connectionQueue); - const editorUntouched = - submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; - if (editorUntouched) { - this.setEditorTextFromQueueSelection(text); - } - this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); - this.showStatus("Queue changed; edit kept in the editor"); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); - return true; - } - const selected = { ...submittedSelection, index: resolvedIndex }; - const queueBefore = this.connectionQueue; let status: AgentConnectionQueuedMessageMutationStatus; try { status = await this.agentConnection.mutateQueuedMessage( - selected.lane, - selected.index, - selected.text, + submittedSelection.lane, + submittedSelection.index, + submittedSelection.text, mutation, ); } catch (error) { @@ -7142,22 +7096,11 @@ export class InteractiveMode { const editorUntouched = submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; if (status === "applied") { - // Same optimistic patch as moveQueueSelection, and the same guard: - // skip when a queue event already replaced the mirror. - const lane = this.connectionQueue[selected.lane]; - if (this.connectionQueue === queueBefore && lane[selected.index] === selected.text) { - if (!trimmed) lane.splice(selected.index, 1); - else if (targetLane === selected.lane) lane[selected.index] = trimmed; - else { - lane.splice(selected.index, 1); - this.connectionQueue[targetLane].push(trimmed); - } - } + await this.refreshConnectionQueue(); if (trimmed) this.editor.addToHistory?.(trimmed); const draft = this.queueSelection.reset(); if (editorUntouched) this.setEditorTextFromQueueSelection(draft); } else { - this.queueSelection.sync(this.connectionQueue); // Enter submissions clear the editor before onSubmit runs; restore the // edit so a failed mutation never swallows it. if (editorUntouched) this.setEditorTextFromQueueSelection(text); @@ -7422,10 +7365,7 @@ export class InteractiveMode { } private getAllQueuedMessages(): { steering: string[]; followUp: string[] } { - return { - steering: [...this.connectionQueue.steering], - followUp: [...this.connectionQueue.followUp], - }; + return this.getConnectionQueue(); } private updatePendingMessagesDisplay(): void { diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index 39547754495..6701006ae5e 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -44,8 +44,6 @@ export class QueueSelection { if (direction > 0) return undefined; this.items = flatten(queue); if (this.items.length === 0) return undefined; - // A drop (sync) keeps the previous draft stashed; do not overwrite it - // with the dropped item's text still sitting in the editor. if (!this.hasStashedDraft) { this.draft = draft; this.hasStashedDraft = true; @@ -62,25 +60,6 @@ export class QueueSelection { return this.items[next]?.text; } - /** - * Track queue changes while browsing: keep the selection when its text is - * still present. Returns the dropped item's text when the selection could - * not be kept, so the caller can restore the stashed draft. - */ - sync(queue: AgentConnectionQueueState): string | undefined { - const selected = this.selected; - this.items = flatten(queue); - if (!selected) return undefined; - const exact = this.items[selected.lane === "steering" ? selected.index : queue.steering.length + selected.index]; - if (exact?.lane === selected.lane && exact.text === selected.text) { - this.cursor = this.items.indexOf(exact); - return undefined; - } - const retargeted = this.items.find((item) => item.lane === selected.lane && item.text === selected.text); - this.cursor = retargeted ? this.items.indexOf(retargeted) : -1; - return retargeted ? undefined : selected.text; - } - /** Called after a mutation or submit resolved the selection. Returns the stashed draft. */ reset(): string { this.cursor = -1; diff --git a/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts b/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts index 21fa99890e5..caa8a1a8e8e 100644 --- a/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts +++ b/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts @@ -26,7 +26,6 @@ type FakeInteractiveMode = { retryAttempt: number; sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] }; }; - connectionQueue: { steering: string[]; followUp: string[] }; agentConnection: { abort: Mock; clearQueue: Mock; @@ -98,7 +97,6 @@ function createInteractiveFake(options: { retryAttempt: options.retryAttempt ?? 0, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, }, - connectionQueue: { steering: [], followUp: [] }, agentConnection: { abort: vi.fn().mockResolvedValue(undefined), clearQueue: vi.fn().mockResolvedValue({ steering: [], followUp: [] }), @@ -172,7 +170,7 @@ describe("InteractiveMode interrupt shortcuts", () => { it("preserves the queue and the draft when interrupting streaming", () => { const mode = createInteractiveFake({ editorText: "draft", streaming: true }); - mode.connectionQueue = { steering: ["steer"], followUp: ["follow"] }; + mode.connectionState.sessionActions = { queuedCount: 2, steering: ["steer"], followUps: ["follow"] }; Reflect.get(InteractiveMode.prototype, "handleCtrlC").call(mode); @@ -180,7 +178,11 @@ describe("InteractiveMode interrupt shortcuts", () => { expect(mode.agentConnection.abortAndClearQueue).not.toHaveBeenCalled(); expect(mode.agentConnection.clearQueue).not.toHaveBeenCalled(); expect(mode.editor.getText()).toBe("draft"); - expect(mode.connectionQueue).toEqual({ steering: ["steer"], followUp: ["follow"] }); + expect(mode.connectionState.sessionActions).toEqual({ + queuedCount: 2, + steering: ["steer"], + followUps: ["follow"], + }); }); it("exits on the second Ctrl+C while the hint is visible", () => { diff --git a/packages/coding-agent/test/interactive-mode-feature-hints.test.ts b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts index d37628873fd..4f69ab90beb 100644 --- a/packages/coding-agent/test/interactive-mode-feature-hints.test.ts +++ b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts @@ -33,7 +33,10 @@ function createMode() { featureHintContainer, loadingAnimation: loader, workingVisible: true, - connectionState: { isStreaming: true }, + connectionState: { + isStreaming: true, + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + }, workingTimer: undefined, workingStartedAt: 0, featureHintDeck, @@ -43,7 +46,6 @@ function createMode() { featureHintAnimationTimer: undefined, featureHintComponent: undefined, featureHintRunPending: false, - connectionQueue: { steering: [], followUp: [] }, compactionQueuedMessages: [], options: { returnToAgentsView: true }, ui: { requestRender }, diff --git a/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts b/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts index 9c033e8c267..eb7b7464979 100644 --- a/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts +++ b/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts @@ -44,7 +44,9 @@ type PromptStashHarness = { }; type PromptStashLiveMarkerHarness = PromptStashHarness & { - connectionQueue: { steering: string[]; followUp: string[] }; + connectionState: { + sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] }; + }; }; type SharedPromptStashHarness = PromptStashHarness & { @@ -628,13 +630,13 @@ describe("InteractiveMode prompt stash", () => { expect(mode.editor.getText()).toBe("half-written draft"); }); - it("drops queued image references from old sessions while keeping stashed images", () => { + it("drops old-session images while keeping stashed images", () => { const base = createPromptStashHarness({ stash: "keep [image #1]" }); const mode: ResetHarness = { ...base, defaultEditor: base.editor, queueSelection: new QueueSelection(), - connectionQueue: { steering: ["old [image #2]"], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, chatContainer: { clear: vi.fn() }, shortcutGuideContainer: { clear: vi.fn() }, pendingMessagesContainer: { clear: vi.fn() }, @@ -661,7 +663,6 @@ describe("InteractiveMode prompt stash", () => { interactiveModeMethods.resetCurrentSessionRenderState.call(mode); - expect(mode.connectionQueue).toEqual({ steering: [], followUp: [] }); expect(mode.promptStash?.text).toBe("keep [image #1]"); expect(mode.pastedImages.has(1)).toBe(true); expect(mode.pastedImages.has(2)).toBe(false); @@ -673,7 +674,7 @@ describe("InteractiveMode prompt stash", () => { ...base, defaultEditor: base.editor, queueSelection: new QueueSelection(), - connectionQueue: { steering: [], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, chatContainer: { clear: vi.fn() }, shortcutGuideContainer: { clear: vi.fn() }, pendingMessagesContainer: { clear: vi.fn() }, @@ -818,7 +819,7 @@ describe("InteractiveMode prompt stash", () => { it("keeps image markers in a stashed prompt live", () => { const mode: PromptStashLiveMarkerHarness = { ...createPromptStashHarness({ stash: "look at [image #7]" }), - connectionQueue: { steering: [], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, }; Object.setPrototypeOf(mode, InteractiveMode.prototype); diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index b53b21c9752..cef6039b348 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -2,9 +2,17 @@ import { describe, expect, it, vi } from "vitest"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { QueueSelection } from "../src/modes/interactive/queue-selection.js"; +type QueueState = { steering: string[]; followUp: string[] }; + type Harness = { queueSelection: QueueSelection; - connectionQueue: { steering: string[]; followUp: string[] }; + connectionState: { + sessionActions: { + queuedCount: number; + steering: readonly string[]; + followUps: readonly string[]; + }; + }; editor: { getText: () => string; setText: (text: string) => void; addToHistory?: (text: string) => void }; isApplyingQueueSelectionText: boolean; pastedImages: Map; @@ -25,8 +33,10 @@ type Harness = { applyQueueSelection: (text: string, targetLane: "steering" | "followUp") => Promise; browseQueueSelection: (direction: -1 | 1) => void; moveQueueSelection: (direction: -1 | 1) => void; + getConnectionQueue: () => QueueState; refreshConnectionQueue: () => Promise; - replaceConnectionQueue: (queue: { steering: string[]; followUp: string[] }) => void; + replaceConnectionQueue: (queue: QueueState) => void; + patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; }; @@ -37,7 +47,13 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate let editorText = ""; const harness = { queueSelection: new QueueSelection(), - connectionQueue: queue, + connectionState: { + sessionActions: { + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }, + }, editor: { getText: () => editorText, setText: (text: string) => { @@ -64,11 +80,16 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate applyQueueSelection: proto.applyQueueSelection, browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, + getConnectionQueue: proto.getConnectionQueue, refreshConnectionQueue: proto.refreshConnectionQueue, replaceConnectionQueue: proto.replaceConnectionQueue, + patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, } as unknown as Harness; + harness.patchConnectionState = (patch) => { + harness.connectionState = { ...harness.connectionState, ...patch }; + }; return harness; } @@ -102,7 +123,7 @@ describe("interactive queued-message editing", () => { lane: "followUp", }); - harness.connectionQueue = { steering: ["s1"], followUp: [] }; + harness.replaceConnectionQueue({ steering: ["s1"], followUp: [] }); harness.browseQueueSelection(-1); await harness.applyQueueSelection(" ", "steering"); expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenLastCalledWith("steering", 0, "s1", { @@ -244,30 +265,6 @@ describe("interactive queued-message editing", () => { expect(harness.editor.getText()).toBe("edited"); }); - it("restores the submitted edit when its queue item vanishes before the mutation starts", async () => { - let releaseMutationChain: () => void = () => {}; - const harness = createHarness({ steering: ["queued"], followUp: [] }); - harness.queueMutationChain = new Promise((resolve) => { - releaseMutationChain = resolve; - }); - harness.editor.setText("draft"); - harness.browseQueueSelection(-1); - harness.editor.setText(""); - const pending = harness.applyQueueSelection("edited", "steering"); - harness.replaceConnectionQueue({ steering: ["remaining"], followUp: [] }); - releaseMutationChain(); - await pending; - expect(harness.agentConnection.mutateQueuedMessage).not.toHaveBeenCalled(); - expect(harness.editor.getText()).toBe("edited"); - expect(harness.queueSelection.hasDraft).toBe(true); - expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); - - harness.browseQueueSelection(-1); - expect(harness.editor.getText()).toBe("remaining"); - harness.browseQueueSelection(1); - expect(harness.editor.getText()).toBe("edited"); - }); - it("does not reset queue browsing in a replacement session when an old mutation completes", async () => { let resolveMutation: (status: string) => void = () => {}; const harness = createHarness({ steering: ["old queued"], followUp: [] }); @@ -288,7 +285,7 @@ describe("interactive queued-message editing", () => { harness.sessionEventGeneration++; harness.pendingQueueEdit = undefined; harness.queueSelection.reset(); - harness.connectionQueue = { steering: ["new queued"], followUp: [] }; + harness.replaceConnectionQueue({ steering: ["new queued"], followUp: [] }); harness.editor.setText("new draft"); harness.browseQueueSelection(-1); @@ -323,104 +320,35 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); - it("serializes rapid moves and addresses the second with the post-move index before any queue event", async () => { - // The daemon's session_action_update can arrive after the mutation response, - // so the local mirror must be updated optimistically between chained moves. - const harness = createHarness({ steering: ["s1", "s2", "s3"], followUp: [] }); - harness.browseQueueSelection(-1); // s3 at index 2 - harness.moveQueueSelection(-1); - harness.moveQueueSelection(-1); - await harness.queueMutationChain; - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 2, "s3", { - type: "move", - direction: -1, - }); - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 1, "s3", { - type: "move", - direction: -1, - }); - expect(harness.connectionQueue.steering).toEqual(["s3", "s1", "s2"]); - }); - - it("preserves a queued reorder when an edit immediately exits browse mode", async () => { + it("refreshes queue state from the connection after a move", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.getQueue.mockResolvedValue({ steering: ["s2", "s1"], followUp: [] }); harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); - const edited = harness.applyQueueSelection("s2 edited", "steering"); await harness.queueMutationChain; - await edited; - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 1, "s2", { - type: "move", - direction: -1, - }); - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 0, "s2", { - type: "replace", - text: "s2 edited", - images: [], - lane: "steering", - }); - }); - - it("optimistically updates the local queue mirror on replace and delete", async () => { - const harness = createHarness({ steering: ["s1", "s2"], followUp: ["f1"] }); - harness.browseQueueSelection(-1); // f1 - await harness.applyQueueSelection("f1 edited", "followUp"); - // An immediate browse must see the new text before the queue event arrives. - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: ["f1 edited"] }); - - harness.browseQueueSelection(-1); // f1 edited - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: [] }); + expect(harness.agentConnection.getQueue).toHaveBeenCalledOnce(); + expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); }); - it("does not double-apply a delete when the queue event lands before the response", async () => { - const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }); - harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { - // The server's session_action_update arrives before the response - // resolves: the mirror is replaced and the selection retargets to - // the remaining same-text item. - harness.connectionQueue = { steering: [], followUp: ["dup"] }; - harness.queueSelection.sync(harness.connectionQueue); - return "applied"; + it("keeps the selected index when duplicate text shifts before an edit", async () => { + let releaseMutationChain: () => void = () => {}; + const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }, "rejected"); + harness.queueMutationChain = new Promise((resolve) => { + releaseMutationChain = resolve; }); - harness.browseQueueSelection(-1); // dup at followUp index 1 - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["dup"] }); - }); - - it("moves the item across lanes in the local mirror on a lane-changing replace", async () => { - const harness = createHarness({ steering: ["s1"], followUp: [] }); - harness.browseQueueSelection(-1); // s1 - await harness.applyQueueSelection("now follow-up", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["now follow-up"] }); - }); - - it("restores the stashed draft when the browsed item is consumed externally", () => { - const harness = createHarness({ steering: [], followUp: ["f1"] }); - harness.editor.setText("draft"); - harness.browseQueueSelection(-1); - expect(harness.editor.getText()).toBe("f1"); - // The item is delivered: the queue update drops the selection. - harness.connectionQueue = { steering: [], followUp: [] }; - const dropped = harness.queueSelection.sync(harness.connectionQueue); - expect(dropped).toBe("f1"); - if (dropped !== undefined && harness.editor.getText() === dropped) { - harness.setEditorTextFromQueueSelection(harness.queueSelection.reset()); - } - expect(harness.editor.getText()).toBe("draft"); - }); - - it("synchronizes queue browsing when a reconnect refresh replaces the queue", async () => { - const harness = createHarness({ steering: [], followUp: ["queued"] }); - harness.editor.setText("draft"); harness.browseQueueSelection(-1); - harness.agentConnection.getQueue.mockResolvedValue({ steering: [], followUp: [] }); - - await harness.refreshConnectionQueue(); + const pending = harness.applyQueueSelection("edited", "followUp"); + harness.replaceConnectionQueue({ steering: [], followUp: ["dup"] }); + releaseMutationChain(); + await pending; - expect(harness.queueSelection.isBrowsing).toBe(false); - expect(harness.editor.getText()).toBe("draft"); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledWith("followUp", 1, "dup", { + type: "replace", + text: "edited", + images: [], + lane: "followUp", + }); }); it("deduplicates repeated image markers in a replace", () => { diff --git a/packages/coding-agent/test/queue-selection.test.ts b/packages/coding-agent/test/queue-selection.test.ts index c45d453bdd4..68ebe27e61d 100644 --- a/packages/coding-agent/test/queue-selection.test.ts +++ b/packages/coding-agent/test/queue-selection.test.ts @@ -26,28 +26,6 @@ describe("QueueSelection", () => { expect(selection.isBrowsing).toBe(false); }); - it("keeps, retargets, or drops the selection when the queue changes", () => { - const selection = new QueueSelection(); - selection.move(queue, "draft", -1); - selection.move(queue, "", -1); - selection.move(queue, "", -1); // s2 - expect(selection.sync({ steering: ["s1", "s2"], followUp: ["f2"] })).toBeUndefined(); - expect(selection.selected).toEqual({ lane: "steering", index: 1, text: "s2" }); - expect(selection.sync({ steering: ["s0", "s2"], followUp: [] })).toBeUndefined(); // retarget by text - expect(selection.selected).toEqual({ lane: "steering", index: 1, text: "s2" }); - expect(selection.sync({ steering: ["s0"], followUp: ["s2"] })).toBe("s2"); // same text, other lane: drop - expect(selection.isBrowsing).toBe(false); - }); - - it("keeps the stashed draft across an external selection drop", () => { - const selection = new QueueSelection(); - selection.move(queue, "my draft", -1); // editing f2 - selection.sync({ steering: [], followUp: [] }); // f2 delivered: selection dropped - expect(selection.isBrowsing).toBe(false); - selection.move({ steering: ["s9"], followUp: [] }, "f2 leftover text", -1); - expect(selection.reset()).toBe("my draft"); - }); - it("reset returns the stashed draft once", () => { const selection = new QueueSelection(); selection.move(queue, "my draft", -1); diff --git a/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts b/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts index ccbe74f109b..6d19c16a663 100644 --- a/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts +++ b/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts @@ -26,11 +26,13 @@ function createFeatureHintMode() { pendingMessagesContainer: new Container(), pendingBashComponents: [], queuedMessagesContainer: new Container(), - connectionQueue: { steering: [] as string[], followUp: [] as string[] }, compactionQueuedMessages: [], loadingAnimation: loader, workingVisible: true, - connectionState: { isStreaming: true }, + connectionState: { + isStreaming: true, + sessionActions: { queuedCount: 0, steering: [] as string[], followUps: [] as string[] }, + }, featureHintDeck: { next: vi.fn(() => ({ id: "test", text: "A useful feature hint." })) }, currentFeatureHint: undefined, featureHintEligibleAt: 0, @@ -148,11 +150,11 @@ describe("ENG-4741 hint placement", () => { vi.advanceTimersByTime(5_000); expect(featureHintContainer.children).toHaveLength(1); - mode.connectionQueue.followUp = ["Continue after this turn"]; + mode.connectionState.sessionActions.followUps = ["Continue after this turn"]; callPrivate(mode, "updatePendingMessagesDisplay"); expect(featureHintContainer.children).toHaveLength(0); - mode.connectionQueue.followUp = []; + mode.connectionState.sessionActions.followUps = []; callPrivate(mode, "updatePendingMessagesDisplay"); expect(featureHintContainer.children).toHaveLength(1); From 3881a53bede2b4a2cc303bdd237d2f60067165ca Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 13:53:22 +0200 Subject: [PATCH 2/8] fix(coding-agent): preserve queue selection after moves --- .../src/modes/interactive/interactive-mode.ts | 29 ++++++----- .../src/modes/interactive/queue-selection.ts | 5 ++ .../test/interactive-queue-edit.test.ts | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8d4f4f34c96..b13a259aab9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7008,21 +7008,20 @@ export class InteractiveMode { } private moveQueueSelection(direction: -1 | 1): void { - if (this.pendingQueueEdit) return; - const submittedSelection = this.queueSelection.selected; - if (!submittedSelection) return; + if (this.pendingQueueEdit || !this.queueSelection.selected) return; const sessionGeneration = this.sessionEventGeneration; void this.enqueueQueueMutation(async () => { if (sessionGeneration !== this.sessionEventGeneration) return; - const status = await this.agentConnection.mutateQueuedMessage( - submittedSelection.lane, - submittedSelection.index, - submittedSelection.text, - { type: "move", direction }, - ); + const selected = this.queueSelection.selected; + if (!selected) return; + const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { + type: "move", + direction, + }); if (sessionGeneration !== this.sessionEventGeneration) return; if (status === "applied") { await this.refreshConnectionQueue(); + this.queueSelection.refreshAfterMove(this.getConnectionQueue(), selected.lane, selected.index + direction); this.ui.requestRender(); } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; reorder not applied"); @@ -7039,9 +7038,7 @@ export class InteractiveMode { * Empty text deletes; otherwise replaces, moving the item to `targetLane`. */ private applyQueueSelection(text: string, targetLane: "steering" | "followUp"): Promise { - if (this.pendingQueueEdit) return Promise.resolve(false); - const submittedSelection = this.queueSelection.selected; - if (!submittedSelection) return Promise.resolve(false); + if (this.pendingQueueEdit || !this.queueSelection.selected) return Promise.resolve(false); const pendingQueueEdit = Symbol("pending-queue-edit"); this.pendingQueueEdit = pendingQueueEdit; const sessionGeneration = this.sessionEventGeneration; @@ -7071,12 +7068,14 @@ export class InteractiveMode { }; return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; + const selected = this.queueSelection.selected; + if (!selected) return true; let status: AgentConnectionQueuedMessageMutationStatus; try { status = await this.agentConnection.mutateQueuedMessage( - submittedSelection.lane, - submittedSelection.index, - submittedSelection.text, + selected.lane, + selected.index, + selected.text, mutation, ); } catch (error) { diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index 6701006ae5e..cebc520ea42 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -60,6 +60,11 @@ export class QueueSelection { return this.items[next]?.text; } + refreshAfterMove(queue: AgentConnectionQueueState, lane: QueueLane, index: number): void { + this.items = flatten(queue); + this.cursor = lane === "steering" ? index : queue.steering.length + index; + } + /** Called after a mutation or submit resolved the selection. Returns the stashed draft. */ reset(): string { this.cursor = -1; diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index cef6039b348..3c5f9a15beb 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { QueuedMessageMutation } from "../src/core/session-action-store.js"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { QueueSelection } from "../src/modes/interactive/queue-selection.js"; @@ -329,6 +330,55 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.getQueue).toHaveBeenCalledOnce(); expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); + }); + + it("uses canonical post-move positions for consecutive moves and an edit", async () => { + const queue = ["s1", "s2", "s3"]; + const harness = createHarness({ steering: queue, followUp: [] }); + harness.agentConnection.getQueue.mockImplementation(async () => ({ steering: [...queue], followUp: [] })); + harness.agentConnection.mutateQueuedMessage.mockImplementation( + async ( + _lane: "steering" | "followUp", + index: number, + expectedText: string, + mutation: QueuedMessageMutation, + ) => { + const item = queue[index]; + if (item !== expectedText) return "rejected"; + if (mutation.type === "move") { + const target = index + mutation.direction; + const neighbor = queue[target]; + if (neighbor === undefined) return "rejected"; + queue[index] = neighbor; + queue[target] = item; + } else if (mutation.type === "replace") { + queue[index] = mutation.text; + } + return "applied"; + }, + ); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + harness.moveQueueSelection(-1); + const edited = harness.applyQueueSelection("s3 edited", "steering"); + await edited; + + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 2, "s3", { + type: "move", + direction: -1, + }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 1, "s3", { + type: "move", + direction: -1, + }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(3, "steering", 0, "s3", { + type: "replace", + text: "s3 edited", + images: [], + lane: "steering", + }); + expect(harness.getConnectionQueue()).toEqual({ steering: ["s3 edited", "s1", "s2"], followUp: [] }); }); it("keeps the selected index when duplicate text shifts before an edit", async () => { From cf8fda37b045ac2e1a3dc1e281903404632ddf7b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 14:11:21 +0200 Subject: [PATCH 3/8] fix(coding-agent): rely on queue events after mutations --- .../src/modes/interactive/interactive-mode.ts | 41 ++++++--------- .../src/modes/interactive/queue-selection.ts | 12 ++++- .../test/interactive-queue-edit.test.ts | 50 +++++++++++++------ 3 files changed, 61 insertions(+), 42 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b13a259aab9..4461e95ad02 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2515,24 +2515,6 @@ export class InteractiveMode { }; } - private async refreshConnectionQueue(): Promise { - this.replaceConnectionQueue(await this.agentConnection.getQueue()); - } - - private replaceConnectionQueue(queue: AgentConnectionQueueState): void { - const sessionActions = this.connectionState?.sessionActions; - if (!sessionActions) return; - this.patchConnectionState({ - sessionActions: { - ...sessionActions, - queuedCount: queue.steering.length + queue.followUp.length, - steering: [...queue.steering], - followUps: [...queue.followUp], - }, - }); - this.updatePendingMessagesDisplay(); - } - private async refreshConnectionCatalog(): Promise { this.invalidateConnectionModelRefresh(); const [state, commands, modelCatalog, resources] = await Promise.all([ @@ -2808,7 +2790,8 @@ export class InteractiveMode { this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); } this.subscribeToAgent(); - await Promise.all([this.refreshConnectionQueue(), this.refreshHeartbeatCatalog().catch(() => undefined)]); + this.updatePendingMessagesDisplay(); + await this.refreshHeartbeatCatalog().catch(() => undefined); await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); @@ -2889,9 +2872,7 @@ export class InteractiveMode { await this.sessionEventQueue; this.resetCurrentSessionRenderState(); await this.renderInitialMessages(); - // The session transition and transcript are already authoritative here; - // a transient queue read must not turn a successful switch into a fatal error. - await this.refreshConnectionQueue().catch(() => undefined); + this.updatePendingMessagesDisplay(); this.syncWorkingLoader(); } @@ -2917,7 +2898,7 @@ export class InteractiveMode { updateFooter: true, }); await this.restoreStreamingMessageFromSnapshot(snapshot.streamingMessage); - await this.refreshConnectionQueue(); + this.updatePendingMessagesDisplay(); if (bashFinished) { if (this.activeBashComponent) { this.activeBashComponent.setComplete(undefined, false); @@ -7020,8 +7001,17 @@ export class InteractiveMode { }); if (sessionGeneration !== this.sessionEventGeneration) return; if (status === "applied") { - await this.refreshConnectionQueue(); - this.queueSelection.refreshAfterMove(this.getConnectionQueue(), selected.lane, selected.index + direction); + await this.sessionEventQueue; + if (sessionGeneration !== this.sessionEventGeneration) return; + const dropped = this.queueSelection.refreshAfterMove( + this.getConnectionQueue(), + selected.lane, + selected.index + direction, + selected.text, + ); + if (dropped !== undefined && this.editor.getText() === selected.text) { + this.setEditorTextFromQueueSelection(dropped); + } this.ui.requestRender(); } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; reorder not applied"); @@ -7095,7 +7085,6 @@ export class InteractiveMode { const editorUntouched = submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; if (status === "applied") { - await this.refreshConnectionQueue(); if (trimmed) this.editor.addToHistory?.(trimmed); const draft = this.queueSelection.reset(); if (editorUntouched) this.setEditorTextFromQueueSelection(draft); diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index cebc520ea42..736cd369c1b 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -60,9 +60,17 @@ export class QueueSelection { return this.items[next]?.text; } - refreshAfterMove(queue: AgentConnectionQueueState, lane: QueueLane, index: number): void { + refreshAfterMove( + queue: AgentConnectionQueueState, + lane: QueueLane, + index: number, + expectedText: string, + ): string | undefined { this.items = flatten(queue); - this.cursor = lane === "steering" ? index : queue.steering.length + index; + const cursor = lane === "steering" ? index : queue.steering.length + index; + const selected = this.items[cursor]; + if (selected?.lane !== lane || selected.index !== index || selected.text !== expectedText) return this.reset(); + this.cursor = cursor; } /** Called after a mutation or submit resolved the selection. Returns the stashed draft. */ diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index 3c5f9a15beb..8d8b51232b8 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -23,10 +23,10 @@ type Harness = { ui: { requestRender: () => void }; agentConnection: { mutateQueuedMessage: ReturnType; - getQueue: ReturnType; abort?: ReturnType; }; sessionEventGeneration: number; + sessionEventQueue: Promise; inputSubmissionGeneration: number; pendingQueueEdit: symbol | undefined; queueMutationChain: Promise; @@ -35,8 +35,6 @@ type Harness = { browseQueueSelection: (direction: -1 | 1) => void; moveQueueSelection: (direction: -1 | 1) => void; getConnectionQueue: () => QueueState; - refreshConnectionQueue: () => Promise; - replaceConnectionQueue: (queue: QueueState) => void; patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; @@ -70,10 +68,10 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate ui: { requestRender: vi.fn() }, agentConnection: { mutateQueuedMessage: vi.fn(async () => mutateResult), - getQueue: vi.fn(async () => ({ steering: [], followUp: [] })), abort: vi.fn(async () => {}), }, sessionEventGeneration: 0, + sessionEventQueue: Promise.resolve(), inputSubmissionGeneration: 0, pendingQueueEdit: undefined, queueMutationChain: Promise.resolve(), @@ -82,8 +80,6 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, getConnectionQueue: proto.getConnectionQueue, - refreshConnectionQueue: proto.refreshConnectionQueue, - replaceConnectionQueue: proto.replaceConnectionQueue, patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, @@ -94,6 +90,15 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate return harness; } +function setQueue(harness: Harness, queue: QueueState): void { + harness.connectionState.sessionActions = { + ...harness.connectionState.sessionActions, + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }; +} + describe("interactive queued-message editing", () => { it("browses into the queue and applies an enter edit as steering", async () => { const harness = createHarness({ steering: ["s1"], followUp: ["f1"] }); @@ -124,7 +129,7 @@ describe("interactive queued-message editing", () => { lane: "followUp", }); - harness.replaceConnectionQueue({ steering: ["s1"], followUp: [] }); + setQueue(harness, { steering: ["s1"], followUp: [] }); harness.browseQueueSelection(-1); await harness.applyQueueSelection(" ", "steering"); expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenLastCalledWith("steering", 0, "s1", { @@ -205,7 +210,7 @@ describe("interactive queued-message editing", () => { const pending = harness.applyQueueSelection(text, "steering"); await vi.waitFor(() => expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce()); - harness.replaceConnectionQueue({ + setQueue(harness, { steering: text.trim() ? [text.trim()] : [], followUp: [], }); @@ -286,7 +291,7 @@ describe("interactive queued-message editing", () => { harness.sessionEventGeneration++; harness.pendingQueueEdit = undefined; harness.queueSelection.reset(); - harness.replaceConnectionQueue({ steering: ["new queued"], followUp: [] }); + setQueue(harness, { steering: ["new queued"], followUp: [] }); harness.editor.setText("new draft"); harness.browseQueueSelection(-1); @@ -321,22 +326,38 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); - it("refreshes queue state from the connection after a move", async () => { + it("refreshes selection from event-driven queue state after a move", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); - harness.agentConnection.getQueue.mockResolvedValue({ steering: ["s2", "s1"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + setQueue(harness, { steering: ["s2", "s1"], followUp: [] }); + return "applied"; + }); harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); await harness.queueMutationChain; - expect(harness.agentConnection.getQueue).toHaveBeenCalledOnce(); expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); }); + it("leaves browse mode when the moved tuple is absent from the event snapshot", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + setQueue(harness, { steering: ["s1"], followUp: [] }); + return "applied"; + }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); + }); + it("uses canonical post-move positions for consecutive moves and an edit", async () => { const queue = ["s1", "s2", "s3"]; const harness = createHarness({ steering: queue, followUp: [] }); - harness.agentConnection.getQueue.mockImplementation(async () => ({ steering: [...queue], followUp: [] })); harness.agentConnection.mutateQueuedMessage.mockImplementation( async ( _lane: "steering" | "followUp", @@ -355,6 +376,7 @@ describe("interactive queued-message editing", () => { } else if (mutation.type === "replace") { queue[index] = mutation.text; } + setQueue(harness, { steering: [...queue], followUp: [] }); return "applied"; }, ); @@ -389,7 +411,7 @@ describe("interactive queued-message editing", () => { }); harness.browseQueueSelection(-1); const pending = harness.applyQueueSelection("edited", "followUp"); - harness.replaceConnectionQueue({ steering: [], followUp: ["dup"] }); + setQueue(harness, { steering: [], followUp: ["dup"] }); releaseMutationChain(); await pending; From bf98b51949c383ab25b83e9eb39e41fc70c518e6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 14:58:34 +0200 Subject: [PATCH 4/8] test(coding-agent): update resync queue display harness --- .../test/suite/regressions/4509-side-questions.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts index bf8de05144d..aff5396798d 100644 --- a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts +++ b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts @@ -880,7 +880,7 @@ describe("ENG-4509 side questions", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(async () => {}), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushCompactionQueue: vi.fn(async () => {}), flushPendingBashComponents: vi.fn(), updateTerminalTitle: vi.fn(), @@ -906,6 +906,7 @@ describe("ENG-4509 side questions", () => { messages: [], }); + expect(fakeThis.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(bashComponent.setComplete).toHaveBeenCalledWith(undefined, false); expect(finishBash).toHaveBeenCalledOnce(); expect(fakeThis.activeBashComponent).toBeUndefined(); From 3836e61da173b98b23e1a97d2f6d2e0c14daaa5c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 15:28:31 +0200 Subject: [PATCH 5/8] test(coding-agent): update snapshot queue display seams --- .../test/interactive-mode-status.test.ts | 101 +++++++----------- 1 file changed, 39 insertions(+), 62 deletions(-) diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 4ef691e164d..b7aad8620f9 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1265,7 +1265,7 @@ describe("InteractiveMode pending bash components", () => { const bashComponent = () => ({ render: () => [], invalidate: () => {} }); - test("keeps pending bash components visible across queue refreshes", () => { + test("keeps pending bash components visible across queue display updates", () => { const pendingMessagesContainer = new Container(); const component = bashComponent(); const fakeThis = { @@ -1435,74 +1435,51 @@ describe("InteractiveMode pending bash components", () => { }); describe("InteractiveMode connection events", () => { - test("rendering a switched session tolerates a transient queue refresh failure", async () => { + test("rendering a switched session updates the pending display from its snapshot", async () => { const harness = { resetCurrentSessionRenderState: vi.fn(), renderInitialMessages: vi.fn(async () => {}), - refreshConnectionQueue: vi.fn(async () => { - throw new Error("queue unavailable"); - }), + updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), }; - await expect( - ( - InteractiveMode.prototype as unknown as { - renderCurrentSessionState(this: typeof harness): Promise; - } - ).renderCurrentSessionState.call(harness), - ).resolves.toBeUndefined(); + await ( + InteractiveMode.prototype as unknown as { + renderCurrentSessionState(this: typeof harness): Promise; + } + ).renderCurrentSessionState.call(harness); + expect(harness.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(harness.syncWorkingLoader).toHaveBeenCalledOnce(); }); - test("degrades heartbeat refresh failures without hiding queue refresh failures during rebind", async () => { + test("degrades heartbeat refresh failures while updating the pending display during rebind", async () => { const rebindCurrentSession = ( InteractiveMode.prototype as unknown as { rebindCurrentSession(this: InteractiveMode): Promise } ).rebindCurrentSession; - const createHarness = ( - refreshConnectionQueue: () => Promise, - refreshHeartbeatCatalog: () => Promise, - ) => - ({ - unsubscribe: undefined, - localSessionHost: undefined, - toolDefinitionCache: { clear: vi.fn() }, - applyRuntimeSettings: vi.fn(), - bindLocalSessionExtensions: true, - bindCurrentSessionExtensions: vi.fn(async () => {}), - subscribeToAgent: vi.fn(), - refreshConnectionQueue, - refreshHeartbeatCatalog, - updateAvailableProviderCount: vi.fn(async () => {}), - updateEditorBorderColor: vi.fn(), - updateTerminalTitle: vi.fn(), - setGoalAnnouncementBaseline: vi.fn(), - syncGoalTray: vi.fn(), - syncWorkingLoader: vi.fn(), - getGoalState: () => emptyGoalState(), - }) as unknown as InteractiveMode; - - await expect( - rebindCurrentSession.call( - createHarness( - vi.fn(async () => {}), - vi.fn(async () => { - throw new Error("heartbeat unavailable"); - }), - ), - ), - ).resolves.toBeUndefined(); + const updatePendingMessagesDisplay = vi.fn(); + const harness = { + unsubscribe: undefined, + localSessionHost: undefined, + toolDefinitionCache: { clear: vi.fn() }, + applyRuntimeSettings: vi.fn(), + bindLocalSessionExtensions: true, + bindCurrentSessionExtensions: vi.fn(async () => {}), + subscribeToAgent: vi.fn(), + updatePendingMessagesDisplay, + refreshHeartbeatCatalog: vi.fn(async () => { + throw new Error("heartbeat unavailable"); + }), + updateAvailableProviderCount: vi.fn(async () => {}), + updateEditorBorderColor: vi.fn(), + updateTerminalTitle: vi.fn(), + setGoalAnnouncementBaseline: vi.fn(), + syncGoalTray: vi.fn(), + syncWorkingLoader: vi.fn(), + getGoalState: () => emptyGoalState(), + } as unknown as InteractiveMode; - await expect( - rebindCurrentSession.call( - createHarness( - vi.fn(async () => { - throw new Error("queue unavailable"); - }), - vi.fn(async () => {}), - ), - ), - ).rejects.toThrow("queue unavailable"); + await expect(rebindCurrentSession.call(harness)).resolves.toBeUndefined(); + expect(updatePendingMessagesDisplay).toHaveBeenCalledOnce(); }); test("restores in-flight assistant state on every session render", async () => { @@ -1738,7 +1715,7 @@ describe("InteractiveMode connection events", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot, - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents: vi.fn(), updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), @@ -1794,7 +1771,7 @@ describe("InteractiveMode connection events", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents, updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), @@ -1832,7 +1809,7 @@ describe("InteractiveMode connection events", () => { }), resetCurrentSessionRenderState: () => calls.push("reset"), renderInitialMessages: async () => calls.push("messages"), - refreshConnectionQueue: async () => calls.push("queue"), + updatePendingMessagesDisplay: () => calls.push("display"), syncWorkingLoader: () => calls.push("loader"), }; @@ -1842,7 +1819,7 @@ describe("InteractiveMode connection events", () => { } ).renderCurrentSessionState.call(fakeThis); - expect(calls).toEqual(["replacement", "reset", "messages", "queue", "loader"]); + expect(calls).toEqual(["replacement", "reset", "messages", "display", "loader"]); }); test("drops a queued source event after the session is replaced", async () => { @@ -3124,7 +3101,7 @@ class EventEmittingReplacementRuntime { describe("InteractiveMode session switch command catalog", () => { test.each(["switchSession", "newSession", "fork"] as const)( - "refreshes an event-emitting in-process %s replacement exactly once before replay", + "refreshes the command catalog for an event-emitting in-process %s replacement exactly once before replay", async (operation) => { const sourceSession = createFakeConnectionSession("source-command"); const targetSession = createFakeConnectionSession("target-command"); @@ -3160,7 +3137,7 @@ describe("InteractiveMode session switch command catalog", () => { calls.push("render"); expect(fakeThis.connectionCommands.map((command) => command.name)).toEqual(["target-command"]); }), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), ui: { requestRender: vi.fn() }, handleEvent: vi.fn(), From 0338d3211f1fbfb42d0c29a9231e3474a634d044 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 24 Aug 2026 16:00:54 +0200 Subject: [PATCH 6/8] fix(coding-agent): reconcile queue browsing on events --- .../src/modes/interactive/interactive-mode.ts | 58 +++++++++++++------ .../src/modes/interactive/queue-selection.ts | 2 +- .../test/interactive-queue-edit.test.ts | 51 +++++++++++++++- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4461e95ad02..5f912e34462 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -252,7 +252,7 @@ import { shouldRunPrimeCliOnboardingSplash, } from "./onboarding.js"; import type { ClientPromptStashStore, PromptStash, PromptStashState } from "./prompt-stash-state.js"; -import { QueueSelection } from "./queue-selection.js"; +import { QueueSelection, type QueueSelectionItem } from "./queue-selection.js"; import { formatResumeHint } from "./resume-hint.js"; import { getAvailableThemes, @@ -1021,6 +1021,7 @@ export class InteractiveMode { private isApplyingQueueSelectionText = false; private queueMutationChain: Promise = Promise.resolve(); private pendingQueueEdit: symbol | undefined; + private pendingQueueMove = false; private shutdownRequested = false; @@ -2647,9 +2648,14 @@ export class InteractiveMode { case "agent_end": this.patchConnectionState({ isStreaming: false, activeToolNames: [] }); break; - case "session_action_update": + case "session_action_update": { this.patchConnectionState({ sessionActions: event.actions }); + const selected = this.queueSelection.selected; + if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { + this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); + } break; + } case "compaction_start": this.patchConnectionState({ isCompacting: true }); break; @@ -2815,6 +2821,7 @@ export class InteractiveMode { this.pendingMessagesContainer.clear(); this.queuedMessagesContainer.clear(); this.pendingQueueEdit = undefined; + this.pendingQueueMove = false; // The selection and its stashed draft belong to the previous session; // every editor draft is cleared below, so discard rather than restore. this.queueSelection.reset(); @@ -6970,6 +6977,17 @@ export class InteractiveMode { } } + private refreshQueueSelectionAt( + queue: AgentConnectionQueueState, + selected: QueueSelectionItem, + index: number, + ): void { + const dropped = this.queueSelection.refreshAt(queue, selected.lane, index, selected.text); + if (dropped !== undefined && this.editor.getText() === selected.text) { + this.setEditorTextFromQueueSelection(dropped); + } + } + private browseQueueSelection(direction: -1 | 1): void { if (this.pendingQueueEdit) return; const text = this.queueSelection.move(this.getConnectionQueue(), this.editor.getText(), direction); @@ -6995,26 +7013,28 @@ export class InteractiveMode { if (sessionGeneration !== this.sessionEventGeneration) return; const selected = this.queueSelection.selected; if (!selected) return; - const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { - type: "move", - direction, - }); - if (sessionGeneration !== this.sessionEventGeneration) return; - if (status === "applied") { - await this.sessionEventQueue; - if (sessionGeneration !== this.sessionEventGeneration) return; - const dropped = this.queueSelection.refreshAfterMove( - this.getConnectionQueue(), + this.pendingQueueMove = true; + try { + const status = await this.agentConnection.mutateQueuedMessage( selected.lane, - selected.index + direction, + selected.index, selected.text, + { + type: "move", + direction, + }, ); - if (dropped !== undefined && this.editor.getText() === selected.text) { - this.setEditorTextFromQueueSelection(dropped); - } - this.ui.requestRender(); - } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); - else this.showStatus("Queue changed; reorder not applied"); + if (sessionGeneration !== this.sessionEventGeneration) return; + if (status === "applied") { + await this.sessionEventQueue; + if (sessionGeneration !== this.sessionEventGeneration) return; + this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index + direction); + this.ui.requestRender(); + } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); + else this.showStatus("Queue changed; reorder not applied"); + } finally { + this.pendingQueueMove = false; + } }).catch((error) => { if (sessionGeneration === this.sessionEventGeneration) { this.showError(error instanceof Error ? error.message : String(error)); diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index 736cd369c1b..e0f34445318 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -60,7 +60,7 @@ export class QueueSelection { return this.items[next]?.text; } - refreshAfterMove( + refreshAt( queue: AgentConnectionQueueState, lane: QueueLane, index: number, diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index 8d8b51232b8..761bb043e8a 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { QueuedMessageMutation } from "../src/core/session-action-store.js"; +import type { AgentConnectionSessionEvent } from "../src/modes/agent-connection/index.js"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { QueueSelection } from "../src/modes/interactive/queue-selection.js"; @@ -29,12 +30,19 @@ type Harness = { sessionEventQueue: Promise; inputSubmissionGeneration: number; pendingQueueEdit: symbol | undefined; + pendingQueueMove: boolean; queueMutationChain: Promise; enqueueQueueMutation: (run: () => Promise) => Promise; applyQueueSelection: (text: string, targetLane: "steering" | "followUp") => Promise; browseQueueSelection: (direction: -1 | 1) => void; moveQueueSelection: (direction: -1 | 1) => void; getConnectionQueue: () => QueueState; + refreshQueueSelectionAt: ( + queue: QueueState, + selected: { lane: "steering" | "followUp"; index: number; text: string }, + index: number, + ) => void; + updateConnectionStateFromEvent: (event: AgentConnectionSessionEvent) => void; patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; @@ -74,12 +82,15 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate sessionEventQueue: Promise.resolve(), inputSubmissionGeneration: 0, pendingQueueEdit: undefined, + pendingQueueMove: false, queueMutationChain: Promise.resolve(), enqueueQueueMutation: proto.enqueueQueueMutation, applyQueueSelection: proto.applyQueueSelection, browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, getConnectionQueue: proto.getConnectionQueue, + refreshQueueSelectionAt: proto.refreshQueueSelectionAt, + updateConnectionStateFromEvent: proto.updateConnectionStateFromEvent, patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, @@ -99,6 +110,17 @@ function setQueue(harness: Harness, queue: QueueState): void { }; } +function emitQueueUpdate(harness: Harness, queue: QueueState): void { + harness.updateConnectionStateFromEvent({ + type: "session_action_update", + actions: { + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }, + }); +} + describe("interactive queued-message editing", () => { it("browses into the queue and applies an enter edit as steering", async () => { const harness = createHarness({ steering: ["s1"], followUp: ["f1"] }); @@ -326,10 +348,33 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); + it("exits browsing when an external event removes the selected item", async () => { + const harness = createHarness({ steering: [], followUp: ["queued"] }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + + emitQueueUpdate(harness, { steering: [], followUp: [] }); + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); + await expect(harness.applyQueueSelection("draft", "steering")).resolves.toBe(false); + expect(harness.agentConnection.mutateQueuedMessage).not.toHaveBeenCalled(); + }); + + it("refreshes browse navigation from external queue events", () => { + const harness = createHarness({ steering: ["s1"], followUp: ["f1", "f2"] }); + harness.browseQueueSelection(-1); + + emitQueueUpdate(harness, { steering: ["s1"], followUp: ["f0", "f2", "f3"] }); + harness.browseQueueSelection(-1); + + expect(harness.editor.getText()).toBe("f0"); + }); + it("refreshes selection from event-driven queue state after a move", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { - setQueue(harness, { steering: ["s2", "s1"], followUp: [] }); + emitQueueUpdate(harness, { steering: ["s2", "s1"], followUp: [] }); return "applied"; }); harness.browseQueueSelection(-1); @@ -343,7 +388,7 @@ describe("interactive queued-message editing", () => { it("leaves browse mode when the moved tuple is absent from the event snapshot", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { - setQueue(harness, { steering: ["s1"], followUp: [] }); + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); return "applied"; }); harness.editor.setText("draft"); @@ -376,7 +421,7 @@ describe("interactive queued-message editing", () => { } else if (mutation.type === "replace") { queue[index] = mutation.text; } - setQueue(harness, { steering: [...queue], followUp: [] }); + emitQueueUpdate(harness, { steering: [...queue], followUp: [] }); return "applied"; }, ); From 938b0a79898fab19d95a1c3b1cc91b4bbf05ccfd Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 26 Aug 2026 11:12:03 +0200 Subject: [PATCH 7/8] fix(coding-agent): retain queued edit after lost move --- .../src/modes/interactive/interactive-mode.ts | 41 ++++++++++--------- .../test/interactive-queue-edit.test.ts | 17 ++++++++ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5f912e34462..0cb50c3ea4d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7079,27 +7079,30 @@ export class InteractiveMode { return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; const selected = this.queueSelection.selected; - if (!selected) return true; let status: AgentConnectionQueuedMessageMutationStatus; - try { - status = await this.agentConnection.mutateQueuedMessage( - selected.lane, - selected.index, - selected.text, - mutation, - ); - } catch (error) { - if (discardStaleSelection()) return true; - // The editor was already cleared by Enter; restore the edit before surfacing the error. - const editorUntouched = - submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; - if (editorUntouched) { - this.setEditorTextFromQueueSelection(text); - } - if (!this.queueSelection.isBrowsing) { - this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); + if (selected) { + try { + status = await this.agentConnection.mutateQueuedMessage( + selected.lane, + selected.index, + selected.text, + mutation, + ); + } catch (error) { + if (discardStaleSelection()) return true; + // The editor was already cleared by Enter; restore the edit before surfacing the error. + const editorUntouched = + submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; + if (editorUntouched) { + this.setEditorTextFromQueueSelection(text); + } + if (!this.queueSelection.isBrowsing) { + this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); + } + throw error; } - throw error; + } else { + status = "rejected"; } if (discardStaleSelection()) return true; const editorUntouched = diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index 761bb043e8a..ca79d849a46 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -400,6 +400,23 @@ describe("interactive queued-message editing", () => { expect(harness.editor.getText()).toBe("draft"); }); + it("keeps a chained edit when the preceding move loses its selection", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "applied"; + }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + harness.editor.setText(""); + await harness.applyQueueSelection("s2 edited", "steering"); + + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); + expect(harness.editor.getText()).toBe("s2 edited"); + expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); + }); + it("uses canonical post-move positions for consecutive moves and an edit", async () => { const queue = ["s1", "s2", "s3"]; const harness = createHarness({ steering: queue, followUp: [] }); From 20c2482a9aca8748f9831e1af81b03be34f592b4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 27 Aug 2026 11:03:08 +0200 Subject: [PATCH 8/8] fix(coding-agent): reconcile queue selection after snapshots --- .../src/modes/interactive/interactive-mode.ts | 31 ++++++---- .../test/interactive-mode-status.test.ts | 60 +++++++++++++++++++ .../test/interactive-queue-edit.test.ts | 17 ++++++ .../regressions/4509-side-questions.test.ts | 1 + 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0cb50c3ea4d..59de91fc4ca 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2637,6 +2637,13 @@ export class InteractiveMode { this.patchConnectionState({ contextUsage: stats.contextUsage }); } + private refreshQueueSelectionFromState(): void { + const selected = this.queueSelection.selected; + if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { + this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); + } + } + private updateConnectionStateFromEvent(event: AgentConnectionSessionEvent): void { if (!this.connectionState) { return; @@ -2648,14 +2655,10 @@ export class InteractiveMode { case "agent_end": this.patchConnectionState({ isStreaming: false, activeToolNames: [] }); break; - case "session_action_update": { + case "session_action_update": this.patchConnectionState({ sessionActions: event.actions }); - const selected = this.queueSelection.selected; - if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { - this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); - } + this.refreshQueueSelectionFromState(); break; - } case "compaction_start": this.patchConnectionState({ isCompacting: true }); break; @@ -2895,6 +2898,7 @@ export class InteractiveMode { private async renderResyncedSession(snapshot: AgentConnectionSnapshot): Promise { const bashFinished = this.isBashRunning() && !snapshot.state.isBashRunning; this.applyConnectionStateSnapshot(snapshot.state); + this.refreshQueueSelectionFromState(); this.restoreTurnStartFromMessages(this.getSessionContextFromConnectionSnapshot(snapshot).messages); this.streamingComponent = undefined; this.streamingMessage = undefined; @@ -7025,12 +7029,15 @@ export class InteractiveMode { }, ); if (sessionGeneration !== this.sessionEventGeneration) return; - if (status === "applied") { - await this.sessionEventQueue; - if (sessionGeneration !== this.sessionEventGeneration) return; - this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index + direction); - this.ui.requestRender(); - } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); + await this.sessionEventQueue; + if (sessionGeneration !== this.sessionEventGeneration) return; + this.refreshQueueSelectionAt( + this.getConnectionQueue(), + selected, + status === "applied" ? selected.index + direction : selected.index, + ); + if (status === "applied") this.ui.requestRender(); + else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; reorder not applied"); } finally { this.pendingQueueMove = false; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index b7aad8620f9..c9cac26fcea 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1673,6 +1673,64 @@ describe("InteractiveMode connection events", () => { expect(fakeThis.renderInitialMessages).toHaveBeenCalledOnce(); }); + test("exits stale queue browsing when a resync replaces the queue snapshot", async () => { + const queueSelection = new QueueSelection(); + let editorText = "draft"; + queueSelection.move({ steering: [], followUp: ["queued"] }, editorText, -1); + editorText = "queued"; + const snapshot: AgentConnectionSnapshot = { + state: createConnectionState({ + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + }), + messages: [], + }; + const fakeThis = { + connectionState: createConnectionState({ + sessionActions: { queuedCount: 1, steering: [], followUps: ["queued"] }, + }), + queueSelection, + pendingQueueEdit: undefined, + pendingQueueMove: false, + isApplyingQueueSelectionText: false, + editor: { + getText: () => editorText, + setText: (text: string) => { + editorText = text; + }, + }, + isBashRunning: () => false, + applyConnectionStateSnapshot: vi.fn(), + restoreTurnStartFromMessages: vi.fn(), + replaceSubagentSummary: vi.fn(), + getSessionContextFromConnectionSnapshot: vi.fn(() => ({ + messages: [], + thinkingLevel: "medium", + model: null, + })), + renderSessionContext: vi.fn(async () => {}), + restoreStreamingMessageFromSnapshot: vi.fn(), + updatePendingMessagesDisplay: vi.fn(), + updateTerminalTitle: vi.fn(), + setGoalAnnouncementBaseline: vi.fn(), + syncGoalTray: vi.fn(), + syncWorkingLoader: vi.fn(), + getGoalState: () => emptyGoalState(), + }; + fakeThis.applyConnectionStateSnapshot.mockImplementation((state: AgentConnectionState) => { + fakeThis.connectionState = state; + }); + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + + await ( + InteractiveMode.prototype as unknown as { + renderResyncedSession(this: unknown, value: AgentConnectionSnapshot): Promise; + } + ).renderResyncedSession.call(fakeThis, snapshot); + + expect(queueSelection.isBrowsing).toBe(false); + expect(editorText).toBe("draft"); + }); + test("preserves client-local work while rendering a resynchronized snapshot", async () => { const sideQuestion = { id: "side-1", status: "running" }; const extensionRequests = new Map([["request-1", { cancelLocal: vi.fn() }]]); @@ -1706,6 +1764,7 @@ describe("InteractiveMode connection events", () => { streamingComponent: {}, streamingMessage: {}, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), updateWorkingLoaderMessage: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1762,6 +1821,7 @@ describe("InteractiveMode connection events", () => { isAgentCompacting: () => true, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), restoreTurnStartFromMessages: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index ca79d849a46..67519c288da 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -42,6 +42,7 @@ type Harness = { selected: { lane: "steering" | "followUp"; index: number; text: string }, index: number, ) => void; + refreshQueueSelectionFromState: () => void; updateConnectionStateFromEvent: (event: AgentConnectionSessionEvent) => void; patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; @@ -90,6 +91,7 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate moveQueueSelection: proto.moveQueueSelection, getConnectionQueue: proto.getConnectionQueue, refreshQueueSelectionAt: proto.refreshQueueSelectionAt, + refreshQueueSelectionFromState: proto.refreshQueueSelectionFromState, updateConnectionStateFromEvent: proto.updateConnectionStateFromEvent, patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, @@ -400,6 +402,21 @@ describe("interactive queued-message editing", () => { expect(harness.editor.getText()).toBe("draft"); }); + it("refreshes selection after a failed move suppresses an external event", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }, "rejected"); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "rejected"; + }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); + }); + it("keeps a chained edit when the preceding move loses its selection", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { diff --git a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts index aff5396798d..e10da95722c 100644 --- a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts +++ b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts @@ -872,6 +872,7 @@ describe("ENG-4509 side questions", () => { isAgentCompacting: () => false, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: [],