Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed queued-message editing so duplicate prompts always target the selected queue entry.
172 changes: 60 additions & 112 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1017,12 +1017,11 @@ 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<void> = Promise.resolve();
private pendingQueueEdit: symbol | undefined;
private pendingQueueMove = false;

private shutdownRequested = false;

Expand Down Expand Up @@ -2510,25 +2509,11 @@ export class InteractiveMode {
}
}

private async refreshConnectionQueue(): Promise<void> {
this.replaceConnectionQueue(await this.agentConnection.getQueue());
}

private replaceConnectionQueue(queue: AgentConnectionQueueState): void {
this.connectionQueue = {
steering: [...queue.steering],
followUp: [...queue.followUp],
private getConnectionQueue(): AgentConnectionQueueState {
return {
steering: [...(this.connectionState?.sessionActions.steering ?? [])],
followUp: [...(this.connectionState?.sessionActions.followUps ?? [])],
};
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);
}
}
this.updatePendingMessagesDisplay();
}

private async refreshConnectionCatalog(): Promise<void> {
Expand Down Expand Up @@ -2663,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;
Expand Down Expand Up @@ -2806,7 +2796,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();
Expand All @@ -2829,8 +2820,8 @@ export class InteractiveMode {
this.shortcutGuideContainer.clear();
this.pendingMessagesContainer.clear();
this.queuedMessagesContainer.clear();
this.connectionQueue = { steering: [], followUp: [] };
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();
Expand Down Expand Up @@ -2888,9 +2879,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();
}

Expand All @@ -2916,7 +2905,7 @@ export class InteractiveMode {
updateFooter: true,
});
await this.restoreStreamingMessageFromSnapshot(snapshot.streamingMessage);
await this.refreshConnectionQueue();
this.updatePendingMessagesDisplay();
Comment thread
snimu marked this conversation as resolved.
Resolved
if (bashFinished) {
if (this.activeBashComponent) {
this.activeBashComponent.setComplete(undefined, false);
Expand Down Expand Up @@ -4420,7 +4409,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;
Expand Down Expand Up @@ -5363,10 +5353,7 @@ export class InteractiveMode {
break;

case "session_action_update": {
this.replaceConnectionQueue({
steering: [...event.actions.steering],
followUp: [...event.actions.followUps],
});
this.updatePendingMessagesDisplay();
Comment thread
snimu marked this conversation as resolved.
Resolved
this.ui.requestRender();
break;
}
Expand Down Expand Up @@ -6990,9 +6977,20 @@ 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.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();
Expand All @@ -7009,48 +7007,34 @@ 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 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,
});
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();
const selected = this.queueSelection.selected;
if (!selected) return;
this.pendingQueueMove = true;
try {
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;
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");
} else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon");
else this.showStatus("Queue changed; reorder not applied");
} finally {
this.pendingQueueMove = false;
Comment thread
snimu marked this conversation as resolved.
Resolved
}
}).catch((error) => {
if (sessionGeneration === this.sessionEventGeneration) {
this.showError(error instanceof Error ? error.message : String(error));
Expand All @@ -7064,9 +7048,7 @@ export class InteractiveMode {
* Empty text deletes; otherwise replaces, moving the item to `targetLane`.
*/
private applyQueueSelection(text: string, targetLane: "steering" | "followUp"): Promise<boolean> {
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;
Expand Down Expand Up @@ -7096,27 +7078,8 @@ 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;
const selected = this.queueSelection.selected;
if (!selected) return true;
Comment thread
snimu marked this conversation as resolved.
Resolved
let status: AgentConnectionQueuedMessageMutationStatus;
try {
status = await this.agentConnection.mutateQueuedMessage(
Expand All @@ -7142,22 +7105,10 @@ 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);
}
}
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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
// edit so a failed mutation never swallows it.
if (editorUntouched) this.setEditorTextFromQueueSelection(text);
Expand Down Expand Up @@ -7422,10 +7373,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 {
Expand Down
28 changes: 10 additions & 18 deletions packages/coding-agent/src/modes/interactive/queue-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,23 +60,17 @@ export class QueueSelection {
return this.items[next]?.text;
}

/**
* Track queue changes while browsing: keep the selection when its text is
Comment thread
snimu marked this conversation as resolved.
Resolved
* 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;
refreshAt(
queue: AgentConnectionQueueState,
lane: QueueLane,
index: number,
expectedText: string,
): string | undefined {
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;
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. */
Expand Down
10 changes: 6 additions & 4 deletions packages/coding-agent/test/interactive-mode-ctrl-c.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: [] }),
Expand Down Expand Up @@ -172,15 +170,19 @@ 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);

expect(mode.agentConnection.abort).toHaveBeenCalledTimes(1);
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", () => {
Expand Down
Loading
Loading