Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
83 changes: 83 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
Loading