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
12 changes: 10 additions & 2 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
})
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "*" })
Expand Down Expand Up @@ -1625,6 +1633,7 @@ export const node = LayerNode.make({
EventV2Bridge.node,
RuntimeFlags.node,
Database.node,
Question.node,
],
})

Expand Down
67 changes: 67 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading