Queue messages during active CLI turns - #71
Merged
Conversation
rgarcia
marked this pull request as ready for review
August 3, 2026 20:27
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Slash commands skip interrupt guard
- Busy-state checks now include both running prompts and interrupt mode, and
/skillis explicitly blocked while busy just like/modeland/tools.
- Busy-state checks now include both running prompts and interrupt mode, and
- ✅ Fixed: Ctrl+C during escape replay
- Ctrl+C now marks interrupt replay as cancelled and clears interrupt-queued text so
interruptTurnexits without launching a replacement prompt.
- Ctrl+C now marks interrupt replay as cancelled and clears interrupt-queued text so
- ✅ Fixed: Failed abort leaves stale queue
interruptTurnnow clearsqueuedDuringInterruptin the abort error path so failed aborts cannot leak stale queued text into later interrupts.
- ✅ Fixed: Interrupt queue snapshot drops late input
- Interrupt replay now drains
queuedDuringInterruptagain immediately before resubmitting so any text queued after the initial snapshot is still included.
- Interrupt replay now drains
Or push these changes by commenting:
@cursor push 46fc5197c5
Preview (46fc5197c5)
diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts
--- a/packages/cli/src/tui/main.ts
+++ b/packages/cli/src/tui/main.ts
@@ -184,10 +184,17 @@
let inflight = 0;
let promptRunning = 0;
let interrupting = false;
+ let interruptReplayCancelled = false;
let queuedDuringInterrupt: string[] = [];
let lastDisplayedError: string | undefined;
const isTurnRunning = (): boolean => inflight > 0 || promptRunning > 0;
+ const drainQueuedDuringInterrupt = (): string[] => {
+ if (queuedDuringInterrupt.length === 0) return [];
+ const queued = queuedDuringInterrupt;
+ queuedDuringInterrupt = [];
+ return queued;
+ };
// Ref of the live model, kept in sync by switchModel so the picker can mark
// it with a ✓. Undefined when opts.modelRef is not a catalog ref.
@@ -234,7 +241,7 @@
* protection, so it refuses up front rather than failing on apply.
*/
const refuseWhileBusy = (command: string): boolean => {
- if (inflight === 0) return false;
+ if (!isTurnRunning() && !interrupting) return false;
messages.addError(`${command} is unavailable while a turn is running`);
requestRender("selector_busy", false, { command });
return true;
@@ -550,6 +557,7 @@
return;
}
if (parsed?.command === "skill") {
+ if (refuseWhileBusy(`/skill:${parsed.name}`)) return;
const skill = (opts.skills ?? []).find((s) => s.name === parsed.name);
if (!skill) {
messages.addError(`unknown skill "${parsed.name}"`);
@@ -597,23 +605,30 @@
const interruptTurn = async (): Promise<void> => {
if (interrupting) return;
interrupting = true;
+ interruptReplayCancelled = false;
try {
const { clearedSteer, clearedFollowUp } = await opts.harness.abort();
+ if (interruptReplayCancelled) {
+ queuedDuringInterrupt = [];
+ return;
+ }
const queued = [
...clearedSteer.map(userMessageText).filter((text): text is string => !!text),
...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text),
- ...queuedDuringInterrupt,
+ ...drainQueuedDuringInterrupt(),
];
- queuedDuringInterrupt = [];
+ if (interruptReplayCancelled) return;
if (queued.length === 0) {
messages.addNotice("turn aborted");
requestRender("input_abort_stream", false, { key: "escape" });
return;
}
+ queued.push(...drainQueuedDuringInterrupt());
const text = queued.join("\n\n");
messages.addNotice(`turn interrupted; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`);
requestRender("input_interrupt_and_send", false, { queued: queued.length });
+ if (interruptReplayCancelled) return;
interrupting = false;
void promptAgent(text).catch((err: unknown) => {
messages.addError((err as Error).message);
@@ -621,11 +636,13 @@
requestRender("queued_prompt_error");
});
} catch (err) {
+ queuedDuringInterrupt = [];
messages.addError((err as Error).message);
debug?.log("input_interrupt_error", { message: (err as Error).message });
requestRender("input_interrupt_error");
} finally {
interrupting = false;
+ interruptReplayCancelled = false;
}
};
@@ -636,6 +653,10 @@
if (activeSelector) return undefined;
if (matchesKey(data, "ctrl+c")) {
if (isTurnRunning() || interrupting) {
+ if (interrupting) {
+ interruptReplayCancelled = true;
+ queuedDuringInterrupt = [];
+ }
void opts.harness.abort();
messages.addNotice("aborted");
debug?.log("input_abort_stream", { key: "ctrl+c" });You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: In-flight steer after interrupt
- I serialized steer and interrupt abort operations through a shared queue and suppressed stale steer success handling when an interrupt epoch changes, preventing post-interrupt steer races.
- ✅ Fixed: Replay clears busy guard early
- I now start replay prompts before clearing interrupt state and await the replay path, so busy tracking stays active with no idle gap during Escape replay handoff.
- ✅ Fixed: Cancelled interrupt still absorbs input
- I preserved submits entered after Ctrl+C cancellation by queuing them during abort and replaying them once abort settles instead of silently dropping them.
Or push these changes by commenting:
@cursor push 2c94769a2f
Preview (2c94769a2f)
diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts
--- a/packages/cli/src/tui/main.ts
+++ b/packages/cli/src/tui/main.ts
@@ -184,6 +184,7 @@
let inflight = 0;
let promptRunning = 0;
let interruptState: { queued: string[]; cancelled: boolean } | undefined;
+ let interruptEpoch = 0;
let lastDisplayedError: string | undefined;
const isTurnRunning = (): boolean => inflight > 0 || promptRunning > 0;
@@ -199,6 +200,9 @@
// Serializes every catalog mutation (`/tools` applies and `/model` switches);
// see mutation-queue.ts for why they must not interleave.
const catalogQueue = createMutationQueue();
+ // Serialize steer/abort queue mutations so interrupt replay can't race a
+ // still-pending steer update.
+ const turnQueue = createMutationQueue();
// Non-null while a picker owns the editor slot and all keyboard input.
let activeSelector: Component | null = null;
@@ -564,12 +568,23 @@
}
if (interruptState) {
interruptState.queued.push(text);
- messages.addNotice("queued for the interrupted turn");
- requestRender("prompt_queued_during_interrupt");
+ if (interruptState.cancelled) {
+ messages.addNotice("queued while aborting turn");
+ requestRender("prompt_queued_during_interrupt_cancelled");
+ } else {
+ messages.addNotice("queued for the interrupted turn");
+ requestRender("prompt_queued_during_interrupt");
+ }
return;
}
if (isTurnRunning()) {
- await opts.harness.steer(text);
+ const steerEpoch = interruptEpoch;
+ const steered = await turnQueue.run(async () => {
+ if (interruptState) return false;
+ await opts.harness.steer(text);
+ return !interruptState;
+ });
+ if (!steered || steerEpoch !== interruptEpoch || interruptState) return;
messages.addNotice("queued for the next available turn");
requestRender("prompt_queued_for_steer");
return;
@@ -598,9 +613,30 @@
if (interruptState) return;
const state: { queued: string[]; cancelled: boolean } = { queued: [], cancelled: false };
interruptState = state;
+ interruptEpoch += 1;
try {
- const { clearedSteer, clearedFollowUp } = await opts.harness.abort();
- if (state.cancelled) return;
+ const { clearedSteer, clearedFollowUp } = await turnQueue.run(() => opts.harness.abort());
+ const replayQueuedPrompt = async (queued: string[], renderReason: string): Promise<void> => {
+ const text = queued.join("\n\n");
+ messages.addNotice(`turn aborted; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`);
+ requestRender(renderReason, false, { queued: queued.length });
+ const replay = promptAgent(text);
+ interruptState = undefined;
+ try {
+ await replay;
+ } catch (err) {
+ messages.addError((err as Error).message);
+ debug?.log("queued_prompt_error", { message: (err as Error).message });
+ requestRender("queued_prompt_error");
+ }
+ };
+ if (state.cancelled) {
+ const cancelledQueued = [...state.queued];
+ state.queued = [];
+ if (cancelledQueued.length === 0) return;
+ await replayQueuedPrompt(cancelledQueued, "input_cancel_interrupt_and_send");
+ return;
+ }
const queued = [
...clearedSteer.map(userMessageText).filter((text): text is string => !!text),
...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text),
@@ -613,15 +649,17 @@
return;
}
- const text = queued.join("\n\n");
messages.addNotice(`turn interrupted; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`);
requestRender("input_interrupt_and_send", false, { queued: queued.length });
+ const replay = promptAgent(queued.join("\n\n"));
interruptState = undefined;
- void promptAgent(text).catch((err: unknown) => {
+ try {
+ await replay;
+ } catch (err) {
messages.addError((err as Error).message);
debug?.log("queued_prompt_error", { message: (err as Error).message });
requestRender("queued_prompt_error");
- });
+ }
} catch (err) {
state.queued = [];
messages.addError((err as Error).message);You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit ab238c0. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


summary
escabort the active work and immediately start a replacement turn with any queued steering or follow-up messages@onkernel/cua-cli0.8.0 and make CLI-only prereleases use published workspace dependencies, with a global-install smoke testupstream behavior
Current pi queues Enter submissions with
streamingBehavior: "steer". Its Escape handler aborts the turn and restores queued messages to the editor. This change uses the same steering mechanism, while intentionally submitting the restored queue immediately after Escape.tests
npm run typecheckPTYWRIGHT_REQUIRED=1 npm test --workspace @onkernel/cua-cli(152 tests)npm test --workspace @onkernel/cua-agentnpm test --workspace @onkernel/cua-ainpm run build:cli@onkernel/cua-cli@0.8.0with npm 11.8.0;cua models -p openrouterpassed@onkernel/cua-cli@0.7.0-pr-71.14passed