From 82a8401d0c5b149cb5b8abed204cfed8d1c21fba Mon Sep 17 00:00:00 2001 From: Joe Black Date: Thu, 2 Jul 2026 15:04:26 -0400 Subject: [PATCH 1/2] fix(opencode): drain queued prompts after session interrupt Resume queued follow-ups when Esc aborts a busy session instead of leaving them paused in the app dock, and abort the active CLI turn so the local prompt queue can continue. Co-authored-by: Cursor --- packages/app/src/pages/session.tsx | 6 --- .../opencode/src/cli/cmd/run/runtime.queue.ts | 5 +++ packages/opencode/src/cli/cmd/run/runtime.ts | 5 +++ .../test/cli/run/runtime.queue.test.ts | 38 +++++++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4c796899c471..55f7ac7be7fa 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1811,7 +1811,6 @@ export default function Page() { if (!item) return if (followupBusy(sessionID)) return if (followup.failed[sessionID] === item.id) return - if (followup.paused[sessionID]) return if (isChildSession()) return if (composer.blocked()) return if (busy(sessionID)) return @@ -1958,11 +1957,6 @@ export default function Page() { onEditLoaded={clearFollowupEdit} shouldQueue={queueEnabled} onQueue={queueFollowup} - onAbort={() => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }} /> } /> diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index d29e03672a20..829731354e46 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -29,6 +29,7 @@ export type QueueInput = { trace?: Trace onSend?: (prompt: RunPrompt) => void onNewSession?: () => void | Promise + bindInterrupt?: (abortActive: () => void) => void run: (prompt: RunPrompt, signal: AbortSignal) => Promise } @@ -314,6 +315,10 @@ export async function runPromptQueue(input: QueueInput): Promise { drain() } + input.bindInterrupt?.(() => { + state.ctrl?.abort() + }) + const offPrompt = input.footer.onPrompt((prompt) => { submit(prompt) }) diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 90cddffa2222..293d0f3e0f93 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -119,6 +119,7 @@ function createSessionResolver(fn?: CreateSession) { type RuntimeState = { shown: boolean aborting: boolean + abortActiveTurn?: () => void model: RunInput["model"] providers: RunProvider[] variants: string[] @@ -343,6 +344,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } state.aborting = true + state.abortActiveTurn?.() void ctx.sdk.session .abort({ sessionID: state.sessionID, @@ -546,6 +548,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer, initialInput: input.initialInput, trace: log, + bindInterrupt: (abortActive) => { + state.abortActiveTurn = abortActive + }, onSend: (prompt) => { state.shown = true state.history.push(prompt) diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index 558373a35d35..9a7265b6a5bb 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -427,6 +427,44 @@ describe("run runtime queue", () => { expect(seen).toEqual(["one", "two"]) }) + test("runs queued prompts after interrupt aborts the active turn", async () => { + const ui = footer() + const seen: string[] = [] + let abortActive: (() => void) | undefined + + const task = runPromptQueue({ + footer: ui.api, + bindInterrupt: (abort) => { + abortActive = abort + }, + run: async (input, signal) => { + seen.push(input.text) + if (input.text !== "one") { + ui.api.close() + return + } + + await new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + + signal.addEventListener("abort", () => resolve(), { once: true }) + }) + }, + }) + + ui.submit("one") + await Promise.resolve() + ui.submit("two") + await Promise.resolve() + abortActive?.() + await task + + expect(seen).toEqual(["one", "two"]) + }) + test("close aborts the active run and drops pending queued work", async () => { const ui = footer() const seen: string[] = [] From dc9d42a6fd31f6982fd954069eb7b2e442f95e5b Mon Sep 17 00:00:00 2001 From: Joe Black Date: Thu, 2 Jul 2026 15:19:23 -0400 Subject: [PATCH 2/2] fix(opencode): CLI-only queued prompt drain after Esc interrupt Drop the duplicate web app followup.paused fix (#33808 owns that surface). Keep opencode run queue interrupt wiring, expose pending queue count, and show a footer status hint when interrupting with queued prompts. Co-authored-by: Cursor --- packages/app/src/pages/session.tsx | 6 ++++++ .../opencode/src/cli/cmd/run/runtime.queue.ts | 13 +++++++++--- packages/opencode/src/cli/cmd/run/runtime.ts | 21 ++++++++++++++++--- .../test/cli/run/runtime.queue.test.ts | 7 +++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 55f7ac7be7fa..4c796899c471 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1811,6 +1811,7 @@ export default function Page() { if (!item) return if (followupBusy(sessionID)) return if (followup.failed[sessionID] === item.id) return + if (followup.paused[sessionID]) return if (isChildSession()) return if (composer.blocked()) return if (busy(sessionID)) return @@ -1957,6 +1958,11 @@ export default function Page() { onEditLoaded={clearFollowupEdit} shouldQueue={queueEnabled} onQueue={queueFollowup} + onAbort={() => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }} /> } /> diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 829731354e46..a96792924865 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -7,6 +7,10 @@ // The queue also handles /exit, /quit, and /new commands, empty-prompt rejection, // and tracks per-turn wall-clock duration for the footer status line. // +// Interrupt (Esc) must abort the active turn's local AbortSignal, not only the +// server session. Otherwise a queued prompt stays blocked behind a run() that +// never observes cancellation. +// // Resolves when the footer closes and all in-flight work finishes. import * as Locale from "@/util/locale" import { MessageID, PartID } from "@/session/schema" @@ -29,7 +33,7 @@ export type QueueInput = { trace?: Trace onSend?: (prompt: RunPrompt) => void onNewSession?: () => void | Promise - bindInterrupt?: (abortActive: () => void) => void + bindInterrupt?: (handle: { abortActive: () => void; pendingQueue: () => number }) => void run: (prompt: RunPrompt, signal: AbortSignal) => Promise } @@ -315,8 +319,11 @@ export async function runPromptQueue(input: QueueInput): Promise { drain() } - input.bindInterrupt?.(() => { - state.ctrl?.abort() + input.bindInterrupt?.({ + abortActive: () => { + state.ctrl?.abort() + }, + pendingQueue: () => state.queue.length, }) const offPrompt = input.footer.onPrompt((prompt) => { diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 293d0f3e0f93..7915d43bf330 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -20,7 +20,7 @@ import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./run import { createRuntimeLifecycle } from "./runtime.lifecycle" import { trace } from "./trace" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" -import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types" +import type { FooterApi, LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types" /** @internal Exported for testing */ export { pickVariant, resolveVariant } from "./variant.shared" @@ -120,6 +120,7 @@ type RuntimeState = { shown: boolean aborting: boolean abortActiveTurn?: () => void + pendingQueuedCount?: () => number model: RunInput["model"] providers: RunProvider[] variants: string[] @@ -226,6 +227,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return state.session } + const interruptFooter: { api?: FooterApi } = {} + const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({ directory: ctx.directory, findFiles: (query) => @@ -344,6 +347,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } state.aborting = true + const pending = state.pendingQueuedCount?.() ?? 0 + if (pending > 0) { + interruptFooter.api?.event({ + type: "stream.patch", + patch: { + queue: pending, + status: pending === 1 ? "interrupting · 1 queued" : `interrupting · ${pending} queued`, + }, + }) + } state.abortActiveTurn?.() void ctx.sdk.session .abort({ @@ -366,6 +379,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }, }) const footer = shell.footer + interruptFooter.api = footer const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => { state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT) } @@ -548,8 +562,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer, initialInput: input.initialInput, trace: log, - bindInterrupt: (abortActive) => { - state.abortActiveTurn = abortActive + bindInterrupt: (handle) => { + state.abortActiveTurn = handle.abortActive + state.pendingQueuedCount = handle.pendingQueue }, onSend: (prompt) => { state.shown = true diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index 9a7265b6a5bb..fee905012d2f 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -431,11 +431,13 @@ describe("run runtime queue", () => { const ui = footer() const seen: string[] = [] let abortActive: (() => void) | undefined + let pendingQueue: (() => number) | undefined const task = runPromptQueue({ footer: ui.api, - bindInterrupt: (abort) => { - abortActive = abort + bindInterrupt: (handle) => { + abortActive = handle.abortActive + pendingQueue = handle.pendingQueue }, run: async (input, signal) => { seen.push(input.text) @@ -459,6 +461,7 @@ describe("run runtime queue", () => { await Promise.resolve() ui.submit("two") await Promise.resolve() + expect(pendingQueue?.()).toBe(1) abortActive?.() await task