diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..f0edf92f0427 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -2,7 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Image } from "@/image/image" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Option, Scope, Schema } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Config } from "@/config/config" @@ -197,9 +197,17 @@ const layer = Layer.effect( time: { start: match.part.state.time.start, end: Date.now() }, }, }) - if (error instanceof PermissionV1.RejectedError || error instanceof Question.RejectedError) { + if (error instanceof PermissionV1.RejectedError) { ctx.blocked = ctx.shouldBreak } + if (error instanceof Question.RejectedError) { + const latestUser = yield* session + .findMessage(ctx.sessionID, (message) => message.info.role === "user") + .pipe(Effect.orDie) + ctx.blocked = + ctx.shouldBreak && + (Option.isNone(latestUser) || latestUser.value.info.id === ctx.assistantMessage.parentID) + } yield* settleToolCall(toolCallID) return true }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..315b31b89e03 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -56,6 +56,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" +import { Question } from "@/question" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -140,6 +141,7 @@ const layer = Layer.effect( const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const database = yield* Database.Service + const question = yield* Question.Service const { db } = database const ops = Effect.fn("SessionPrompt.ops")(function* () { return { @@ -1057,6 +1059,12 @@ const layer = Layer.effect( const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) + yield* Effect.forEach( + (yield* question.list()).filter((item) => item.sessionID === input.sessionID), + (item) => question.reject(item.id).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ) + const permissions: PermissionV1.Rule[] = [] for (const [t, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) @@ -1625,6 +1633,7 @@ export const node = LayerNode.make({ EventV2Bridge.node, RuntimeFlags.node, Database.node, + Question.node, ], }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..c777bfabd7a3 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1496,6 +1496,73 @@ it.instance("prompt submitted during an active run is included in the next LLM i }), ) +it.instance("prompt submitted while question tool is pending resumes the loop", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const question = yield* Question.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const secondID = MessageID.ascending() + + yield* llm.tool("question", { + questions: [ + { + question: "Should I continue?", + header: "Continue", + options: [ + { label: "Yes", description: "Continue working" }, + { label: "No", description: "Stop working" }, + ], + }, + ], + }) + yield* llm.text("continued") + + const first = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "start" }], + }) + .pipe(Effect.forkChild) + + yield* pollWithTimeout( + question.list().pipe(Effect.map((items) => (items.some((item) => item.sessionID === chat.id) ? true : undefined))), + "timed out waiting for question", + ) + + const second = yield* prompt + .prompt({ + sessionID: chat.id, + messageID: secondID, + agent: "build", + model: ref, + parts: [{ type: "text", text: "So?" }], + }) + .pipe(Effect.forkChild) + + yield* awaitWithTimeout(llm.wait(2), "timed out waiting for loop to resume", "2 seconds") + const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)]) + expect(Exit.isSuccess(firstExit)).toBe(true) + expect(Exit.isSuccess(secondExit)).toBe(true) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const last = msgs.findLast((msg) => msg.info.role === "assistant") + expect(last?.info.role).toBe("assistant") + if (!last || last.info.role !== "assistant") throw new Error("expected resumed assistant") + expect(last.info.parentID).toBe(secondID) + expect(last.parts.some((part) => part.type === "text" && part.text === "continued")).toBe(true) + const questionTool = msgs + .flatMap((msg) => msg.parts) + .find((part): part is SessionV1.ToolPart => part.type === "tool" && part.tool === "question") + expect(questionTool?.state.status).toBe("error") + expect(JSON.stringify((yield* llm.inputs).at(-1)?.messages)).toContain("So?") + expect(yield* question.list()).toEqual([]) + }), +) + it.instance("assertNotBusy fails with BusyError when loop running", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg)