Skip to content

Queue messages during active CLI turns - #71

Merged
rgarcia merged 6 commits into
mainfrom
hypeship/queue-turn-interrupts
Aug 3, 2026
Merged

Queue messages during active CLI turns#71
rgarcia merged 6 commits into
mainfrom
hypeship/queue-turn-interrupts

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

summary

  • queue messages submitted during an active TUI turn as steering input instead of starting a conflicting prompt
  • make esc abort the active work and immediately start a replacement turn with any queued steering or follow-up messages
  • cover normal steering and interrupt-and-send behavior with PTY-driven CLI tests
  • prepare @onkernel/cua-cli 0.8.0 and make CLI-only prereleases use published workspace dependencies, with a global-install smoke test

upstream 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 typecheck
  • PTYWRIGHT_REQUIRED=1 npm test --workspace @onkernel/cua-cli (152 tests)
  • npm test --workspace @onkernel/cua-agent
  • npm test --workspace @onkernel/cua-ai
  • npm run build:cli
  • packed and globally installed @onkernel/cua-cli@0.8.0 with npm 11.8.0; cua models -p openrouter passed
  • manual QA of @onkernel/cua-cli@0.7.0-pr-71.14 passed

@rgarcia
rgarcia marked this pull request as ready for review August 3, 2026 20:27

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /skill is explicitly blocked while busy just like /model and /tools.
  • ✅ Fixed: Ctrl+C during escape replay
    • Ctrl+C now marks interrupt replay as cancelled and clears interrupt-queued text so interruptTurn exits without launching a replacement prompt.
  • ✅ Fixed: Failed abort leaves stale queue
    • interruptTurn now clears queuedDuringInterrupt in 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 queuedDuringInterrupt again immediately before resubmitting so any text queued after the initial snapshot is still included.

Create PR

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.

Comment thread packages/cli/src/tui/main.ts
Comment thread packages/cli/src/tui/main.ts Outdated
Comment thread packages/cli/src/tui/main.ts Outdated
Comment thread packages/cli/src/tui/main.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

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.

Create PR

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.

Comment thread packages/cli/src/tui/main.ts
Comment thread packages/cli/src/tui/main.ts Outdated
Comment thread packages/cli/src/tui/main.ts
@rgarcia
rgarcia merged commit e40312d into main Aug 3, 2026
6 checks passed
@rgarcia
rgarcia deleted the hypeship/queue-turn-interrupts branch August 3, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant