From c478ad52a98a9026613d6bbcceaf983bf303d481 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 14:48:54 -0400 Subject: [PATCH] fix(session): show interrupt progress immediately --- .../app/src/components/prompt-input-v2.tsx | 1 + packages/app/src/components/prompt-input.tsx | 82 ++++++++------ .../src/components/prompt-input/contracts.ts | 1 + .../components/prompt-input/submit.test.ts | 71 ++++++++++++ .../app/src/components/prompt-input/submit.ts | 14 ++- .../prompt-input/transient-state.ts | 3 + packages/core/test/session-runner.test.ts | 103 +++++++++++++++++- .../src/v2/components/prompt-input/index.tsx | 6 +- .../v2/components/prompt-input/interaction.ts | 1 + packages/tui/src/component/prompt/index.tsx | 31 +++++- 10 files changed, 268 insertions(+), 45 deletions(-) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 36256d3920..bb490cc404 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -402,6 +402,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }, submit: { stopping, + pending: submission.stopping, working, onSubmit: () => void submission.handleSubmit(new Event("submit")), onStop: () => void submission.abort(), diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3ff6cf6aa5..b8ba766476 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -261,6 +261,9 @@ export const PromptInput: Component = (props) => { () => prompt.capture(), Math.floor(Math.random() * EXAMPLES.length), ) + createEffect(() => { + if (!working()) setStore("stopping", false) + }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ opacity: value, @@ -283,9 +286,10 @@ export const PromptInput: Component = (props) => { .join("") return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 }) - const stopping = createMemo(() => working() && blank()) + const stopAction = createMemo(() => working() && blank()) const tip = () => { - if (stopping()) { + if (store.stopping) return {language.t("prompt.action.stop")}... + if (stopAction()) { return (
{language.t("prompt.action.stop")} @@ -1198,36 +1202,46 @@ export const PromptInput: Component = (props) => { return permission.isAutoAccepting(id, sdk().directory) }) - const { abort, handleSubmit } = - props.submission ?? - createPromptSubmit({ - prompt, - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => { - if (!popover) return closePopover() - setStore({ popover, slashMenu: false, slashMenuQuery: "" }) - }, - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - model: props.controls.model.selection, + const { + abort: requestAbort, + handleSubmit, + stopping: requestStopping, + } = props.submission ?? + createPromptSubmit({ + prompt, + info, + imageAttachments, + commentCount, + autoAccept: () => accepting(), + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + resetHistoryNavigation(true) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => { + if (!popover) return closePopover() + setStore({ popover, slashMenu: false, slashMenuQuery: "" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + const abort = () => { + if (store.stopping || requestStopping?.()) return Promise.resolve() + setStore("stopping", true) + return Promise.resolve(requestAbort()).finally(() => { + if (working()) setStore("stopping", false) }) + } const handleKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { @@ -1579,12 +1593,12 @@ export const PromptInput: Component = (props) => {
diff --git a/packages/app/src/components/prompt-input/contracts.ts b/packages/app/src/components/prompt-input/contracts.ts index a38f45d925..1af5f4cb81 100644 --- a/packages/app/src/components/prompt-input/contracts.ts +++ b/packages/app/src/components/prompt-input/contracts.ts @@ -8,6 +8,7 @@ export type PromptInputState = ReturnType export type PromptInputSubmission = { abort: () => Promise | void handleSubmit: (event: Event) => Promise | void + stopping?: () => boolean } export type PromptInputControls = { diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 0cfa52d80d..e0720496f6 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -46,6 +46,9 @@ let selected = "/repo/worktree-a" let variant: string | undefined let permissionServer = "server-a" let createSessionGate: Promise | undefined +let interruptGate: Promise | undefined +let interruptCalls = 0 +let interruptFailure = false let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const [promptStore, setPromptStore] = createStore({ @@ -121,6 +124,11 @@ const clientFor = (directory: string) => { shell: async (input: { sessionID: string; id?: string; command: string }) => { sentShell.push(input) }, + interrupt: async () => { + interruptCalls++ + await interruptGate + if (interruptFailure) throw new Error("interrupt failed") + }, }, }, session: { @@ -310,11 +318,74 @@ beforeEach(() => { variant = undefined permissionServer = "server-a" createSessionGate = undefined + interruptGate = undefined + interruptCalls = 0 + interruptFailure = false serverSessionSyncs = 0 for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) describe("prompt submit worktree selection", () => { + test("reports stopping immediately and suppresses duplicate interrupts", async () => { + params = { id: "session-1" } + let release = () => {} + interruptGate = new Promise((resolve) => { + release = resolve + }) + let working = true + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => working, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: () => 0, + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + }) + + const first = submit.abort() + const second = submit.abort() + + expect(submit.stopping()).toBe(true) + expect(interruptCalls).toBe(1) + release() + await Promise.all([first, second]) + working = false + expect(submit.stopping()).toBe(false) + }) + + test("clears stopping when the interrupt request fails", async () => { + params = { id: "session-1" } + interruptFailure = true + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => true, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: () => 0, + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + }) + + await submit.abort() + + expect(submit.stopping()).toBe(false) + }) + test("reads the latest worktree accessor value per submit", async () => { const submit = createPromptSubmit({ prompt, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 4a40281567..a3e777a4a6 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -4,7 +4,7 @@ import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { batch, startTransition, type Accessor } from "solid-js" +import { batch, createSignal, startTransition, type Accessor } from "solid-js" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -263,6 +263,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { const params = useParams() const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() + const [stopping, setStopping] = createSignal(false) + const isStopping = () => { + if (input.working()) return stopping() + setStopping(false) + return false + } const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const errorMessage = (err: unknown) => { @@ -276,8 +282,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const abort = async () => { + if (isStopping()) return const sessionID = params.id if (!sessionID) return Promise.resolve() + setStopping(true) serverSync().session.set("todo", sessionID, []) @@ -289,11 +297,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { queued.abort.abort() queued.cleanup() pending.delete(key) + setStopping(false) return Promise.resolve() } return sdk() .api.session.interrupt({ sessionID }) - .catch(() => {}) + .catch(() => setStopping(false)) } const restoreCommentItems = ( @@ -649,5 +658,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { return { abort, handleSubmit, + stopping: isStopping, } } diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts index 29f2f182b3..3df1e57b37 100644 --- a/packages/app/src/components/prompt-input/transient-state.ts +++ b/packages/app/src/components/prompt-input/transient-state.ts @@ -12,6 +12,7 @@ export type PromptInputTransientState = { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean + stopping: boolean } function resetPromptInputTransientState(setStore: SetStoreFunction) { @@ -24,6 +25,7 @@ function resetPromptInputTransientState(setStore: SetStoreFunction, pla draggingType: null, mode: "normal", applyingHistory: false, + stopping: false, }) createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 73eb2b77a5..d3a553cd80 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -28,7 +28,7 @@ import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { Form } from "@opencode-ai/core/form" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -355,6 +355,18 @@ const pluginSupervisor = Layer.succeed( flush: Effect.suspend(() => pluginFlushHook), }), ) +let snapshotCaptureHook: () => Effect.Effect = () => Effect.succeed(undefined) +let snapshotFilesHook: (input: Snapshot.CompareInput) => Effect.Effect = () => + Effect.succeed([]) +const snapshots = Layer.succeed( + Snapshot.Service, + Snapshot.Service.of({ + capture: () => snapshotCaptureHook(), + files: (input) => snapshotFilesHook(input), + diff: () => Effect.succeed([]), + restore: () => Effect.void, + }), +) const promptCatalog = Layer.mock(Catalog.Service, { provider: { get: () => Effect.succeed(undefined), @@ -370,7 +382,7 @@ const promptCatalog = Layer.mock(Catalog.Service, { }, }) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ - [Snapshot.node, Snapshot.noopLayer], + [Snapshot.node, snapshots], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], [InstructionBuiltIns.node, systemContext], @@ -437,7 +449,7 @@ const it = testEffect( [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillInstructions.node, skillInstructions], [ReferenceInstructions.node, referenceInstructions], - [Snapshot.node, Snapshot.noopLayer], + [Snapshot.node, snapshots], [SessionExecution.node, execution], [Config.node, config], [PluginSupervisor.node, pluginSupervisor], @@ -493,6 +505,8 @@ const setup = Effect.gen(function* () { systemLoadHook = Effect.void modelResolveHook = Effect.void pluginFlushHook = Effect.void + snapshotCaptureHook = () => Effect.succeed(undefined) + snapshotFilesHook = () => Effect.succeed([]) currentModel = model skillBaselines.clear() toolBarrier = undefined @@ -3741,6 +3755,89 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("waits for the end snapshot before interrupted settlement", () => + Effect.gen(function* () { + const session = yield* setup + const endCaptureStarted = yield* Deferred.make() + const releaseEndCapture = yield* Deferred.make() + const runSettled = yield* Deferred.make() + const interruptSettled = yield* Deferred.make() + let captures = 0 + snapshotCaptureHook = () => { + captures++ + if (captures === 1) return Effect.succeed(Snapshot.ID.make("snapshot-start")) + return Deferred.succeed(endCaptureStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseEndCapture)), + Effect.as(Snapshot.ID.make("snapshot-end")), + ) + } + snapshotFilesHook = () => Effect.succeed([RelativePath.make("changed.txt")]) + yield* admit(session, "Interrupt during end snapshot") + const stream = yield* TestLLM.gate + + const run = yield* session + .resume(sessionID) + .pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild) + yield* stream.started + const interrupt = yield* session + .interrupt(sessionID) + .pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild) + yield* Deferred.await(endCaptureStarted) + + expect(yield* Deferred.isDone(interruptSettled)).toBe(false) + expect(yield* Deferred.isDone(runSettled)).toBe(false) + + yield* Deferred.succeed(releaseEndCapture, undefined) + yield* Fiber.join(interrupt) + expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) + expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({ + finish: "error", + error: { type: "aborted", message: "Step interrupted" }, + snapshot: { + start: "snapshot-start", + end: "snapshot-end", + files: ["changed.txt"], + }, + }) + }), + ) + + it.effect("waits for unrelated database transactions before interrupted settlement", () => + Effect.gen(function* () { + const session = yield* setup + const { db } = yield* Database.Service + const transactionStarted = yield* Deferred.make() + const releaseTransaction = yield* Deferred.make() + const interruptSettled = yield* Deferred.make() + yield* admit(session, "Interrupt during database contention") + const stream = yield* TestLLM.gate + + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* stream.started + const transaction = yield* db + .transaction(() => + Deferred.succeed(transactionStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseTransaction))), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(transactionStarted) + const interrupt = yield* session + .interrupt(sessionID) + .pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild) + yield* Effect.yieldNow + + expect(yield* Deferred.isDone(interruptSettled)).toBe(false) + + yield* Deferred.succeed(releaseTransaction, undefined) + yield* Fiber.join(transaction) + yield* Fiber.join(interrupt) + expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) + expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({ + finish: "error", + error: { type: "aborted", message: "Step interrupted" }, + }) + }), + ) + it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index ff4ff0f1d4..14b814ebdb 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -257,6 +257,7 @@ export function PromptInputV2(props: PromptInputV2Props) { + pending?: Accessor working?: Accessor onSubmit: () => void onStop: () => void diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 44bc076438..dbcf6bef5f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -167,6 +167,16 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => data.session.status(props.sessionID ?? "")) + const [stoppingSession, setStoppingSession] = createSignal() + const stopping = createMemo(() => stoppingSession() === props.sessionID && status() === "running") + createEffect( + on( + () => [props.sessionID, status()] as const, + ([, current]) => { + if (current === "idle") setStoppingSession(undefined) + }, + ), + ) const history = usePromptHistory() const stash = usePromptStash() const keymap = Keymap.use() @@ -466,7 +476,7 @@ export function Prompt(props: PromptProps) { name: "session.interrupt", category: "Session", palette: undefined, - enabled: status() === "running", + enabled: status() === "running" && !stopping(), run: () => { if (auto()?.visible) return if (!input.focused) return @@ -484,9 +494,12 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { - void client.api.session.interrupt({ - sessionID: props.sessionID, - }) + setStoppingSession(props.sessionID) + void client.api.session + .interrupt({ + sessionID: props.sessionID, + }) + .catch(() => setStoppingSession(undefined)) setStore("interrupt", 0) } dialog.clear() @@ -1797,6 +1810,16 @@ export function Prompt(props: PromptProps) { + + + + [⋯]}> + + + + Stopping... + +