From 84645ea5f9d714735369ad0231aa1e74175b7429 Mon Sep 17 00:00:00 2001 From: Rituparna W Date: Tue, 25 Aug 2026 01:16:45 +0200 Subject: [PATCH 1/2] fix(opencode): resume queued prompts after interrupt --- packages/opencode/src/session/prompt.ts | 22 ++++++ packages/opencode/test/session/prompt.test.ts | 71 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..36ee971ddb93 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -152,6 +152,28 @@ const layer = Layer.effect( const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* Effect.logInfo("cancel", { "session.id": sessionID }) yield* state.cancel(sessionID) + // Prompts submitted mid-run persist their user message and join the run, + // so interrupting strands them in history unanswered. Restart the loop + // when the latest user message never got an assistant reply. Unlike the + // loop's own exit check, the interrupted turn's aborted assistant + // message counts as handled, so a plain abort does not re-run its turn. + const history = yield* sessions.messages({ sessionID }).pipe(Effect.option) + if (Option.isNone(history)) return + const lastUser = history.value.findLast((msg) => msg.info.role === "user") + if (!lastUser) return + // Synthetic-only messages (e.g. workspace move reminders) expect no reply + if (!lastUser.parts.some((part) => part.type !== "text" || !part.synthetic)) return + if (history.value.some((msg) => msg.info.role === "assistant" && msg.info.parentID === lastUser.info.id)) + return + yield* loop({ sessionID }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("queued prompt resume failed", { + "session.id": sessionID, + cause: Cause.pretty(cause), + }), + ), + Effect.forkIn(scope), + ) }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..8bbf52ae297c 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1395,6 +1395,77 @@ it.instance( 10_000, ) +it.instance("cancel resumes a prompt queued mid-run", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + const gate = yield* Deferred.make() + yield* llm.hold("first", deferredAsPromise(gate)) + yield* llm.text("second") + + const a = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "first" }], + }) + .pipe(Effect.forkChild) + + yield* llm.wait(1) + yield* waitForBusy(chat.id) + + const id = MessageID.ascending() + const b = yield* prompt + .prompt({ + sessionID: chat.id, + messageID: id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "second" }], + }) + .pipe(Effect.forkChild) + + yield* pollWithTimeout( + sessions + .messages({ sessionID: chat.id }) + .pipe( + Effect.map((msgs) => (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined)), + ), + "timed out waiting for queued prompt to save", + ) + + yield* prompt.cancel(chat.id) + yield* Deferred.succeed(gate, void 0) + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + + // the queued prompt is answered without being re-sent + yield* llm.wait(2) + const msg = yield* pollWithTimeout( + sessions.messages({ sessionID: chat.id }).pipe( + Effect.map((msgs) => + msgs.findLast( + (msg) => + msg.info.role === "assistant" && + msg.info.parentID === id && + msg.parts.some((part) => part.type === "text" && part.text === "second"), + ), + ), + ), + "timed out waiting for queued prompt reply", + ) + if (msg.info.role !== "assistant") throw new Error("expected assistant reply") + expect(msg.info.finish).toBe("stop") + }), + 10_000, +) + // Queue semantics noLLMServer.instance("concurrent loop callers get same result", () => From 054cea534b05c666284dafead0243e566be9caf1 Mon Sep 17 00:00:00 2001 From: Rituparna W Date: Tue, 25 Aug 2026 01:26:22 +0200 Subject: [PATCH 2/2] test(opencode): pin bare cancel not restarting the loop --- packages/opencode/test/session/prompt.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 8bbf52ae297c..4382aa1a660f 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1466,6 +1466,43 @@ it.instance("cancel resumes a prompt queued mid-run", () => 10_000, ) +it.instance("cancel without queued input does not restart the loop", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.hang + const a = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "only question" }], + }) + .pipe(Effect.forkChild) + + yield* llm.wait(1) + yield* waitForBusy(chat.id) + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(a) + expect(Exit.isSuccess(exit)).toBe(true) + + // Negative assertion: nothing to wait on when the loop correctly stays + // stopped, so give a wrongly-resumed loop a window to fire its request. + yield* Effect.sleep("500 millis") + expect(yield* llm.calls).toBe(1) + const last = yield* sessions + .messages({ sessionID: chat.id }) + .pipe(Effect.map((msgs) => msgs.findLast((msg) => msg.info.role === "assistant"))) + if (last?.info.role !== "assistant") throw new Error("expected aborted turn") + expect(last.info.finish).toBeUndefined() + }), + 10_000, +) + // Queue semantics noLLMServer.instance("concurrent loop callers get same result", () =>