diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..a9afe5dc9d83 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -152,6 +152,21 @@ const layer = Layer.effect( const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* Effect.logInfo("cancel", { "session.id": sessionID }) yield* state.cancel(sessionID) + // Input admitted while the aborted turn was running ("queued" messages) + // is persisted as user messages behind the last assistant one. Without a + // fresh loop those stay in history forever, never answered — restart the + // loop for them instead of dropping the queue on the floor. + const messages = yield* MessageV2.filterCompactedEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.catch(() => Effect.succeed([] as SessionV1.WithParts[])), + ) + const { user: lastUser, assistant: lastAssistant } = MessageV2.latest(messages) + if (lastUser !== undefined && (lastAssistant === undefined || lastUser.id > lastAssistant.id)) { + yield* Effect.logInfo("cancel restarting loop for stranded queued input", { + "session.id": sessionID, + }) + yield* loop({ sessionID }).pipe(Effect.ignore, 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 491ad06aaf47..4ad0d06fcef6 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1128,6 +1128,89 @@ it.instance("cancel records MessageAbortedError on interrupted process", () => }), ) +it.instance( + "cancel restarts the loop for queued input stranded behind the aborted turn", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id) + + yield* llm.hang + yield* user(chat.id, "in-flight question") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) + + // TUI "queued" input is a user message persisted while the turn runs. + yield* user(chat.id, "queued question") + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + const abortedID = Exit.isSuccess(exit) && exit.value.info.role === "assistant" ? exit.value.info.id : undefined + expect(abortedID).toBeDefined() + + // A fresh loop must answer the queued message: a finished assistant + // reply newer than the aborted one appears (the single hanging + // response is consumed by the aborted turn, so the restarted turn + // lands on the server's default reply). + yield* pollWithTimeout( + Effect.gen(function* () { + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const answered = msgs.find( + (msg) => + msg.info.role === "assistant" && + msg.info.id > abortedID! && + msg.info.finish !== undefined, + ) + return answered === undefined ? undefined : (true as const) + }), + "queued input was never answered after cancel", + "3 seconds", + ) + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + 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 status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id) + + yield* llm.hang + const inflight = yield* user(chat.id, "in-flight question") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + + // Only the interrupted (in-flight) question existed — nothing was + // queued behind it, so no new turn may start and nothing gets answered. + yield* Effect.sleep("500 millis") + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const answered = msgs.filter( + (msg) => msg.info.role === "assistant" && msg.info.id > inflight.id && msg.info.finish !== undefined, + ) + expect(answered).toHaveLength(0) + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + 10_000, +) + raceNoLLMServer.instance( "finalizes assistant when cancelled before processor creation completes", () =>