Skip to content

Commit 4fee18b

Browse files
committed
Handle interrupt input races
1 parent ab238c0 commit 4fee18b

2 files changed

Lines changed: 31 additions & 13 deletions

File tree

‎packages/cli/src/tui/main.ts‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
183183
let assistantBuffer: AssistantBuffer | undefined;
184184
let inflight = 0;
185185
let promptRunning = 0;
186+
let turnRevision = 0;
186187
let interruptState: { queued: string[]; cancelled: boolean } | undefined;
187188
let lastDisplayedError: string | undefined;
188189

@@ -564,12 +565,14 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
564565
}
565566
if (interruptState) {
566567
interruptState.queued.push(text);
567-
messages.addNotice("queued for the interrupted turn");
568+
messages.addNotice(interruptState.cancelled ? "queued for after abort" : "queued for the interrupted turn");
568569
requestRender("prompt_queued_during_interrupt");
569570
return;
570571
}
571572
if (isTurnRunning()) {
573+
const revision = turnRevision;
572574
await opts.harness.steer(text);
575+
if (revision !== turnRevision) return;
573576
messages.addNotice("queued for the next available turn");
574577
requestRender("prompt_queued_for_steer");
575578
return;
@@ -594,13 +597,34 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
594597
void runPrompt(trimmed);
595598
};
596599

600+
const startQueuedPrompt = (queued: string[], notice: string): void => {
601+
messages.addNotice(`${notice}; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`);
602+
requestRender("queued_prompt_start", false, { queued: queued.length });
603+
void promptAgent(queued.join("\n\n")).catch((err: unknown) => {
604+
messages.addError((err as Error).message);
605+
debug?.log("queued_prompt_error", { message: (err as Error).message });
606+
requestRender("queued_prompt_error");
607+
});
608+
};
609+
597610
const interruptTurn = async (): Promise<void> => {
598611
if (interruptState) return;
599612
const state: { queued: string[]; cancelled: boolean } = { queued: [], cancelled: false };
600613
interruptState = state;
614+
turnRevision += 1;
615+
messages.addNotice("interrupting…");
616+
requestRender("input_interrupt_start", false, { key: "escape" });
601617
try {
602618
const { clearedSteer, clearedFollowUp } = await opts.harness.abort();
603-
if (state.cancelled) return;
619+
if (state.cancelled) {
620+
const queued = state.queued;
621+
state.queued = [];
622+
if (queued.length > 0) {
623+
interruptState = undefined;
624+
startQueuedPrompt(queued, "abort complete");
625+
}
626+
return;
627+
}
604628
const queued = [
605629
...clearedSteer.map(userMessageText).filter((text): text is string => !!text),
606630
...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text),
@@ -613,15 +637,8 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
613637
return;
614638
}
615639

616-
const text = queued.join("\n\n");
617-
messages.addNotice(`turn interrupted; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`);
618-
requestRender("input_interrupt_and_send", false, { queued: queued.length });
619640
interruptState = undefined;
620-
void promptAgent(text).catch((err: unknown) => {
621-
messages.addError((err as Error).message);
622-
debug?.log("queued_prompt_error", { message: (err as Error).message });
623-
requestRender("queued_prompt_error");
624-
});
641+
startQueuedPrompt(queued, "turn interrupted");
625642
} catch (err) {
626643
state.queued = [];
627644
messages.addError((err as Error).message);
@@ -647,6 +664,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
647664
return { consume: true };
648665
}
649666
if (isTurnRunning()) {
667+
turnRevision += 1;
650668
void opts.harness.abort();
651669
messages.addNotice("aborted");
652670
debug?.log("input_abort_stream", { key: "ctrl+c" });

‎packages/cli/test/tui.fixture.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,13 @@ suite("TUI ptywright scenarios", () => {
168168
await session.waitForVisible("queued for the next available turn", { timeoutMs: WAIT_MS });
169169

170170
session.press(KeyEscape);
171+
await session.waitForVisible("interrupting…", { timeoutMs: WAIT_MS });
171172
session.press(KeyCtrlC);
172173
await session.waitForVisible("aborted", { timeoutMs: WAIT_MS });
173-
await session.waitForStable(700, { timeoutMs: WAIT_MS });
174-
assert.doesNotMatch(session.snapshot().visible, /fixture response/);
175-
176174
session.line("recover after cancelling replay");
175+
await session.waitForVisible("queued for after abort", { timeoutMs: WAIT_MS });
177176
await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS });
177+
assert.doesNotMatch(session.snapshot().visible, /turn interrupted; sending 1 queued message/);
178178

179179
await exitFixture(session);
180180
});

0 commit comments

Comments
 (0)