Parcourir la source

refactor(session): remove prompt async facade exports

Kit Langton il y a 3 jours
Parent
commit
2fc5b00537

+ 76 - 85
packages/opencode/src/cli/cmd/github.ts

@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
 import { Git } from "@/git"
 import { setTimeout as sleep } from "node:timers/promises"
 import { Process } from "@/util/process"
+import { Effect } from "effect"
 
 type GitHubAuthor = {
   login: string
@@ -937,96 +938,86 @@ export const GithubRunCommand = cmd({
       async function chat(message: string, files: PromptFiles = []) {
         console.log("Sending message to opencode...")
 
-        const result = await SessionPrompt.prompt({
-          sessionID: session.id,
-          messageID: MessageID.ascending(),
-          variant,
-          model: {
-            providerID,
-            modelID,
-          },
-          // agent is omitted - server will use default_agent from config or fall back to "build"
-          parts: [
-            {
-              id: PartID.ascending(),
-              type: "text",
-              text: message,
-            },
-            ...files.flatMap((f) => [
-              {
-                id: PartID.ascending(),
-                type: "file" as const,
-                mime: f.mime,
-                url: `data:${f.mime};base64,${f.content}`,
-                filename: f.filename,
-                source: {
-                  type: "file" as const,
-                  text: {
-                    value: f.replacement,
-                    start: f.start,
-                    end: f.end,
-                  },
-                  path: f.filename,
-                },
+        return AppRuntime.runPromise(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const result = yield* prompt.prompt({
+              sessionID: session.id,
+              messageID: MessageID.ascending(),
+              variant,
+              model: {
+                providerID,
+                modelID,
               },
-            ]),
-          ],
-        })
-
-        // result should always be assistant just satisfying type checker
-        if (result.info.role === "assistant" && result.info.error) {
-          const err = result.info.error
-          console.error("Agent error:", err)
-
-          if (err.name === "ContextOverflowError") {
-            throw new Error(formatPromptTooLargeError(files))
-          }
-
-          const errorMsg = err.data?.message || ""
-          throw new Error(`${err.name}: ${errorMsg}`)
-        }
-
-        const text = extractResponseText(result.parts)
-        if (text) return text
-
-        // No text part (tool-only or reasoning-only) - ask agent to summarize
-        console.log("Requesting summary from agent...")
-        const summary = await SessionPrompt.prompt({
-          sessionID: session.id,
-          messageID: MessageID.ascending(),
-          variant,
-          model: {
-            providerID,
-            modelID,
-          },
-          tools: { "*": false }, // Disable all tools to force text response
-          parts: [
-            {
-              id: PartID.ascending(),
-              type: "text",
-              text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
-            },
-          ],
-        })
-
-        if (summary.info.role === "assistant" && summary.info.error) {
-          const err = summary.info.error
-          console.error("Summary agent error:", err)
+              // agent is omitted - server will use default_agent from config or fall back to "build"
+              parts: [
+                {
+                  id: PartID.ascending(),
+                  type: "text",
+                  text: message,
+                },
+                ...files.flatMap((f) => [
+                  {
+                    id: PartID.ascending(),
+                    type: "file" as const,
+                    mime: f.mime,
+                    url: `data:${f.mime};base64,${f.content}`,
+                    filename: f.filename,
+                    source: {
+                      type: "file" as const,
+                      text: {
+                        value: f.replacement,
+                        start: f.start,
+                        end: f.end,
+                      },
+                      path: f.filename,
+                    },
+                  },
+                ]),
+              ],
+            })
 
-          if (err.name === "ContextOverflowError") {
-            throw new Error(formatPromptTooLargeError(files))
-          }
+            if (result.info.role === "assistant" && result.info.error) {
+              const err = result.info.error
+              console.error("Agent error:", err)
+              if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
+              throw new Error(`${err.name}: ${err.data?.message || ""}`)
+            }
 
-          const errorMsg = err.data?.message || ""
-          throw new Error(`${err.name}: ${errorMsg}`)
-        }
+            const text = extractResponseText(result.parts)
+            if (text) return text
+
+            console.log("Requesting summary from agent...")
+            const summary = yield* prompt.prompt({
+              sessionID: session.id,
+              messageID: MessageID.ascending(),
+              variant,
+              model: {
+                providerID,
+                modelID,
+              },
+              tools: { "*": false },
+              parts: [
+                {
+                  id: PartID.ascending(),
+                  type: "text",
+                  text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
+                },
+              ],
+            })
 
-        const summaryText = extractResponseText(summary.parts)
-        if (!summaryText) {
-          throw new Error("Failed to get summary from agent")
-        }
+            if (summary.info.role === "assistant" && summary.info.error) {
+              const err = summary.info.error
+              console.error("Summary agent error:", err)
+              if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
+              throw new Error(`${err.name}: ${err.data?.message || ""}`)
+            }
 
-        return summaryText
+            const summaryText = extractResponseText(summary.parts)
+            if (!summaryText) throw new Error("Failed to get summary from agent")
+            return summaryText
+          }),
+        )
       }
 
       async function getOidcToken() {

+ 18 - 12
packages/opencode/src/server/instance/session.ts

@@ -341,13 +341,17 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const body = c.req.valid("json")
-        await SessionPrompt.command({
-          sessionID,
-          messageID: body.messageID,
-          model: body.providerID + "/" + body.modelID,
-          command: Command.Default.INIT,
-          arguments: "",
-        })
+        await AppRuntime.runPromise(
+          SessionPrompt.Service.use((svc) =>
+            svc.command({
+              sessionID,
+              messageID: body.messageID,
+              model: body.providerID + "/" + body.modelID,
+              command: Command.Default.INIT,
+              arguments: "",
+            }),
+          ),
+        )
         return c.json(true)
       },
     )
@@ -407,7 +411,7 @@ export const SessionRoutes = lazy(() =>
         }),
       ),
       async (c) => {
-        await SessionPrompt.cancel(c.req.valid("param").sessionID)
+        await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
         return c.json(true)
       },
     )
@@ -875,7 +879,9 @@ export const SessionRoutes = lazy(() =>
         return stream(c, async (stream) => {
           const sessionID = c.req.valid("param").sessionID
           const body = c.req.valid("json")
-          const msg = await SessionPrompt.prompt({ ...body, sessionID })
+          const msg = await AppRuntime.runPromise(
+            SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
+          )
           stream.write(JSON.stringify(msg))
         })
       },
@@ -904,7 +910,7 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const body = c.req.valid("json")
-        SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
+        AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => {
           log.error("prompt_async failed", { sessionID, error: err })
           Bus.publish(Session.Event.Error, {
             sessionID,
@@ -948,7 +954,7 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const body = c.req.valid("json")
-        const msg = await SessionPrompt.command({ ...body, sessionID })
+        const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
         return c.json(msg)
       },
     )
@@ -980,7 +986,7 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const body = c.req.valid("json")
-        const msg = await SessionPrompt.shell({ ...body, sessionID })
+        const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
         return c.json(msg)
       },
     )

+ 0 - 27
packages/opencode/src/session/prompt.ts

@@ -46,7 +46,6 @@ import { Process } from "@/util/process"
 import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
 import { EffectLogger } from "@/effect/logger"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { TaskTool, type TaskPromptOps } from "@/tool/task"
 import { SessionRunState } from "./run-state"
 
@@ -1708,8 +1707,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       ),
     ),
   )
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
   export const PromptInput = z.object({
     sessionID: SessionID.zod,
     messageID: MessageID.zod.optional(),
@@ -1777,26 +1774,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
   })
   export type PromptInput = z.infer<typeof PromptInput>
 
-  export async function prompt(input: PromptInput) {
-    return runPromise((svc) => svc.prompt(PromptInput.parse(input)))
-  }
-
-  export async function resolvePromptParts(template: string) {
-    return runPromise((svc) => svc.resolvePromptParts(z.string().parse(template)))
-  }
-
-  export async function cancel(sessionID: SessionID) {
-    return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID)))
-  }
-
   export const LoopInput = z.object({
     sessionID: SessionID.zod,
   })
 
-  export async function loop(input: z.infer<typeof LoopInput>) {
-    return runPromise((svc) => svc.loop(LoopInput.parse(input)))
-  }
-
   export const ShellInput = z.object({
     sessionID: SessionID.zod,
     messageID: MessageID.zod.optional(),
@@ -1811,10 +1792,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
   })
   export type ShellInput = z.infer<typeof ShellInput>
 
-  export async function shell(input: ShellInput) {
-    return runPromise((svc) => svc.shell(ShellInput.parse(input)))
-  }
-
   export const CommandInput = z.object({
     messageID: MessageID.zod.optional(),
     sessionID: SessionID.zod,
@@ -1838,10 +1815,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
   })
   export type CommandInput = z.infer<typeof CommandInput>
 
-  export async function command(input: CommandInput) {
-    return runPromise((svc) => svc.command(CommandInput.parse(input)))
-  }
-
   /** @internal Exported for testing */
   export function createStructuredOutputTool(input: {
     schema: Record<string, any>

+ 2 - 6
packages/opencode/test/server/session-actions.test.ts

@@ -1,9 +1,7 @@
-import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { Effect } from "effect"
+import { afterEach, describe, expect, mock, test } from "bun:test"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 import { Session } from "../../src/session"
-import { SessionPrompt } from "../../src/session/prompt"
 import { Log } from "../../src/util/log"
 import { tmpdir } from "../fixture/fixture"
 
@@ -15,20 +13,18 @@ afterEach(async () => {
 })
 
 describe("session action routes", () => {
-  test("abort route calls SessionPrompt.cancel", async () => {
+  test("abort route returns success", async () => {
     await using tmp = await tmpdir({ git: true })
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
         const session = await Session.create({})
-        const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
         const app = Server.Default().app
 
         const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
 
         expect(res.status).toBe(200)
         expect(await res.json()).toBe(true)
-        expect(cancel).toHaveBeenCalledWith(session.id)
 
         await Session.remove(session.id)
       },

+ 34 - 40
packages/opencode/test/session/prompt-effect.test.ts

@@ -210,7 +210,7 @@ function makeHttp() {
       Layer.provide(SystemPrompt.defaultLayer),
       Layer.provideMerge(deps),
     ),
-  )
+  ).pipe(Layer.provide(summary))
 }
 
 const it = testEffect(makeHttp())
@@ -384,25 +384,23 @@ it.live("loop calls LLM and returns assistant message", () =>
 it.live("static loop returns assistant text through local provider", () =>
   provideTmpdirServer(
     Effect.fnUntraced(function* ({ llm }) {
-      const session = yield* Effect.promise(() =>
-        Session.create({
-          title: "Prompt provider",
-          permission: [{ permission: "*", pattern: "*", action: "allow" }],
-        }),
-      )
+      const prompt = yield* SessionPrompt.Service
+      const sessions = yield* Session.Service
+      const session = yield* sessions.create({
+        title: "Prompt provider",
+        permission: [{ permission: "*", pattern: "*", action: "allow" }],
+      })
 
-      yield* Effect.promise(() =>
-        SessionPrompt.prompt({
-          sessionID: session.id,
-          agent: "build",
-          noReply: true,
-          parts: [{ type: "text", text: "hello" }],
-        }),
-      )
+      yield* prompt.prompt({
+        sessionID: session.id,
+        agent: "build",
+        noReply: true,
+        parts: [{ type: "text", text: "hello" }],
+      })
 
       yield* llm.text("world")
 
-      const result = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
+      const result = yield* prompt.loop({ sessionID: session.id })
       expect(result.info.role).toBe("assistant")
       expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
       expect(yield* llm.hits).toHaveLength(1)
@@ -415,40 +413,36 @@ it.live("static loop returns assistant text through local provider", () =>
 it.live("static loop consumes queued replies across turns", () =>
   provideTmpdirServer(
     Effect.fnUntraced(function* ({ llm }) {
-      const session = yield* Effect.promise(() =>
-        Session.create({
-          title: "Prompt provider turns",
-          permission: [{ permission: "*", pattern: "*", action: "allow" }],
-        }),
-      )
+      const prompt = yield* SessionPrompt.Service
+      const sessions = yield* Session.Service
+      const session = yield* sessions.create({
+        title: "Prompt provider turns",
+        permission: [{ permission: "*", pattern: "*", action: "allow" }],
+      })
 
-      yield* Effect.promise(() =>
-        SessionPrompt.prompt({
-          sessionID: session.id,
-          agent: "build",
-          noReply: true,
-          parts: [{ type: "text", text: "hello one" }],
-        }),
-      )
+      yield* prompt.prompt({
+        sessionID: session.id,
+        agent: "build",
+        noReply: true,
+        parts: [{ type: "text", text: "hello one" }],
+      })
 
       yield* llm.text("world one")
 
-      const first = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
+      const first = yield* prompt.loop({ sessionID: session.id })
       expect(first.info.role).toBe("assistant")
       expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
 
-      yield* Effect.promise(() =>
-        SessionPrompt.prompt({
-          sessionID: session.id,
-          agent: "build",
-          noReply: true,
-          parts: [{ type: "text", text: "hello two" }],
-        }),
-      )
+      yield* prompt.prompt({
+        sessionID: session.id,
+        agent: "build",
+        noReply: true,
+        parts: [{ type: "text", text: "hello two" }],
+      })
 
       yield* llm.text("world two")
 
-      const second = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
+      const second = yield* prompt.loop({ sessionID: session.id })
       expect(second.info.role).toBe("assistant")
       expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
 

+ 283 - 215
packages/opencode/test/session/prompt.test.ts

@@ -2,6 +2,7 @@ import path from "path"
 import { describe, expect, test } from "bun:test"
 import { NamedError } from "@opencode-ai/util/error"
 import { fileURLToPath } from "url"
+import { Effect, Layer } from "effect"
 import { Instance } from "../../src/project/instance"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Session } from "../../src/session"
@@ -12,6 +13,12 @@ import { tmpdir } from "../fixture/fixture"
 
 Log.init({ print: false })
 
+function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
+  return Effect.runPromise(
+    fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
+  )
+}
+
 function defer<T>() {
   let resolve!: (value: T | PromiseLike<T>) => void
   const promise = new Promise<T>((done) => {
@@ -104,34 +111,39 @@ describe("session.prompt missing file", () => {
 
     await Instance.provide({
       directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-
-        const missing = path.join(tmp.path, "does-not-exist.ts")
-        const msg = await SessionPrompt.prompt({
-          sessionID: session.id,
-          agent: "build",
-          noReply: true,
-          parts: [
-            { type: "text", text: "please review @does-not-exist.ts" },
-            {
-              type: "file",
-              mime: "text/plain",
-              url: `file://${missing}`,
-              filename: "does-not-exist.ts",
-            },
-          ],
-        })
+      fn: () =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({})
+
+            const missing = path.join(tmp.path, "does-not-exist.ts")
+            const msg = yield* prompt.prompt({
+              sessionID: session.id,
+              agent: "build",
+              noReply: true,
+              parts: [
+                { type: "text", text: "please review @does-not-exist.ts" },
+                {
+                  type: "file",
+                  mime: "text/plain",
+                  url: `file://${missing}`,
+                  filename: "does-not-exist.ts",
+                },
+              ],
+            })
 
-        if (msg.info.role !== "user") throw new Error("expected user message")
+            if (msg.info.role !== "user") throw new Error("expected user message")
 
-        const hasFailure = msg.parts.some(
-          (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
-        )
-        expect(hasFailure).toBe(true)
+            const hasFailure = msg.parts.some(
+              (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
+            )
+            expect(hasFailure).toBe(true)
 
-        await Session.remove(session.id)
-      },
+            yield* sessions.remove(session.id)
+          }),
+        ),
     })
   })
 
@@ -149,39 +161,44 @@ describe("session.prompt missing file", () => {
 
     await Instance.provide({
       directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-
-        const missing = path.join(tmp.path, "still-missing.ts")
-        const msg = await SessionPrompt.prompt({
-          sessionID: session.id,
-          agent: "build",
-          noReply: true,
-          parts: [
-            {
-              type: "file",
-              mime: "text/plain",
-              url: `file://${missing}`,
-              filename: "still-missing.ts",
-            },
-            { type: "text", text: "after-file" },
-          ],
-        })
+      fn: () =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({})
+
+            const missing = path.join(tmp.path, "still-missing.ts")
+            const msg = yield* prompt.prompt({
+              sessionID: session.id,
+              agent: "build",
+              noReply: true,
+              parts: [
+                {
+                  type: "file",
+                  mime: "text/plain",
+                  url: `file://${missing}`,
+                  filename: "still-missing.ts",
+                },
+                { type: "text", text: "after-file" },
+              ],
+            })
 
-        if (msg.info.role !== "user") throw new Error("expected user message")
+            if (msg.info.role !== "user") throw new Error("expected user message")
 
-        const stored = await MessageV2.get({
-          sessionID: session.id,
-          messageID: msg.info.id,
-        })
-        const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
+            const stored = MessageV2.get({
+              sessionID: session.id,
+              messageID: msg.info.id,
+            })
+            const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
 
-        expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
-        expect(text[1]?.includes("Read tool failed to read")).toBe(true)
-        expect(text[2]).toBe("after-file")
+            expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
+            expect(text[1]?.includes("Read tool failed to read")).toBe(true)
+            expect(text[2]).toBe("after-file")
 
-        await Session.remove(session.id)
-      },
+            yield* sessions.remove(session.id)
+          }),
+        ),
     })
   })
 })
@@ -197,31 +214,36 @@ describe("session.prompt special characters", () => {
 
     await Instance.provide({
       directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const template = "Read @file#name.txt"
-        const parts = await SessionPrompt.resolvePromptParts(template)
-        const fileParts = parts.filter((part) => part.type === "file")
-
-        expect(fileParts.length).toBe(1)
-        expect(fileParts[0].filename).toBe("file#name.txt")
-        expect(fileParts[0].url).toContain("%23")
-
-        const decodedPath = fileURLToPath(fileParts[0].url)
-        expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
-
-        const message = await SessionPrompt.prompt({
-          sessionID: session.id,
-          parts,
-          noReply: true,
-        })
-        const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
-        const textParts = stored.parts.filter((part) => part.type === "text")
-        const hasContent = textParts.some((part) => part.text.includes("special content"))
-        expect(hasContent).toBe(true)
-
-        await Session.remove(session.id)
-      },
+      fn: () =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({})
+            const template = "Read @file#name.txt"
+            const parts = yield* prompt.resolvePromptParts(template)
+            const fileParts = parts.filter((part) => part.type === "file")
+
+            expect(fileParts.length).toBe(1)
+            expect(fileParts[0].filename).toBe("file#name.txt")
+            expect(fileParts[0].url).toContain("%23")
+
+            const decodedPath = fileURLToPath(fileParts[0].url)
+            expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
+
+            const message = yield* prompt.prompt({
+              sessionID: session.id,
+              parts,
+              noReply: true,
+            })
+            const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
+            const textParts = stored.parts.filter((part) => part.type === "text")
+            const hasContent = textParts.some((part) => part.text.includes("special content"))
+            expect(hasContent).toBe(true)
+
+            yield* sessions.remove(session.id)
+          }),
+        ),
     })
   })
 })
@@ -273,21 +295,26 @@ describe("session.prompt regression", () => {
 
       await Instance.provide({
         directory: tmp.path,
-        fn: async () => {
-          const session = await Session.create({ title: "Prompt regression" })
-          const result = await SessionPrompt.prompt({
-            sessionID: session.id,
-            agent: "build",
-            parts: [{ type: "text", text: "Where is SessionProcessor?" }],
-          })
-
-          expect(result.info.role).toBe("assistant")
-          expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
-
-          const msgs = await Session.messages({ sessionID: session.id })
-          expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
-          expect(calls).toBe(1)
-        },
+        fn: () =>
+          run(
+            Effect.gen(function* () {
+              const prompt = yield* SessionPrompt.Service
+              const sessions = yield* Session.Service
+              const session = yield* sessions.create({ title: "Prompt regression" })
+              const result = yield* prompt.prompt({
+                sessionID: session.id,
+                agent: "build",
+                parts: [{ type: "text", text: "Where is SessionProcessor?" }],
+              })
+
+              expect(result.info.role).toBe("assistant")
+              expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
+
+              const msgs = yield* sessions.messages({ sessionID: session.id })
+              expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
+              expect(calls).toBe(1)
+            }),
+          ),
       })
     } finally {
       server.stop(true)
@@ -342,36 +369,45 @@ describe("session.prompt regression", () => {
 
       await Instance.provide({
         directory: tmp.path,
-        fn: async () => {
-          const session = await Session.create({ title: "Prompt cancel regression" })
-          const run = SessionPrompt.prompt({
-            sessionID: session.id,
-            agent: "build",
-            parts: [{ type: "text", text: "Cancel me" }],
-          })
-
-          await ready.promise
-          await SessionPrompt.cancel(session.id)
-
-          const result = await Promise.race([
-            run,
-            new Promise<never>((_, reject) =>
-              setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
-            ),
-          ])
-
-          expect(result.info.role).toBe("assistant")
-          if (result.info.role === "assistant") {
-            expect(result.info.error?.name).toBe("MessageAbortedError")
-          }
-
-          const msgs = await Session.messages({ sessionID: session.id })
-          const last = msgs.findLast((msg) => msg.info.role === "assistant")
-          expect(last?.info.role).toBe("assistant")
-          if (last?.info.role === "assistant") {
-            expect(last.info.error?.name).toBe("MessageAbortedError")
-          }
-        },
+        fn: () =>
+          run(
+            Effect.gen(function* () {
+              const prompt = yield* SessionPrompt.Service
+              const sessions = yield* Session.Service
+              const session = yield* sessions.create({ title: "Prompt cancel regression" })
+              const task = Effect.runPromise(
+                prompt.prompt({
+                  sessionID: session.id,
+                  agent: "build",
+                  parts: [{ type: "text", text: "Cancel me" }],
+                }),
+              )
+
+              yield* Effect.promise(() => ready.promise)
+              yield* prompt.cancel(session.id)
+
+              const result = yield* Effect.promise(() =>
+                Promise.race([
+                  task,
+                  new Promise<never>((_, reject) =>
+                    setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
+                  ),
+                ]),
+              )
+
+              expect(result.info.role).toBe("assistant")
+              if (result.info.role === "assistant") {
+                expect(result.info.error?.name).toBe("MessageAbortedError")
+              }
+
+              const msgs = yield* sessions.messages({ sessionID: session.id })
+              const last = msgs.findLast((msg) => msg.info.role === "assistant")
+              expect(last?.info.role).toBe("assistant")
+              if (last?.info.role === "assistant") {
+                expect(last.info.error?.name).toBe("MessageAbortedError")
+              }
+            }),
+          ),
       })
     } finally {
       server.stop(true)
@@ -399,45 +435,50 @@ describe("session.prompt agent variant", () => {
 
       await Instance.provide({
         directory: tmp.path,
-        fn: async () => {
-          const session = await Session.create({})
-
-          const other = await SessionPrompt.prompt({
-            sessionID: session.id,
-            agent: "build",
-            model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
-            noReply: true,
-            parts: [{ type: "text", text: "hello" }],
-          })
-          if (other.info.role !== "user") throw new Error("expected user message")
-          expect(other.info.model.variant).toBeUndefined()
-
-          const match = await SessionPrompt.prompt({
-            sessionID: session.id,
-            agent: "build",
-            noReply: true,
-            parts: [{ type: "text", text: "hello again" }],
-          })
-          if (match.info.role !== "user") throw new Error("expected user message")
-          expect(match.info.model).toEqual({
-            providerID: ProviderID.make("openai"),
-            modelID: ModelID.make("gpt-5.2"),
-            variant: "xhigh",
-          })
-          expect(match.info.model.variant).toBe("xhigh")
-
-          const override = await SessionPrompt.prompt({
-            sessionID: session.id,
-            agent: "build",
-            noReply: true,
-            variant: "high",
-            parts: [{ type: "text", text: "hello third" }],
-          })
-          if (override.info.role !== "user") throw new Error("expected user message")
-          expect(override.info.model.variant).toBe("high")
-
-          await Session.remove(session.id)
-        },
+        fn: () =>
+          run(
+            Effect.gen(function* () {
+              const prompt = yield* SessionPrompt.Service
+              const sessions = yield* Session.Service
+              const session = yield* sessions.create({})
+
+              const other = yield* prompt.prompt({
+                sessionID: session.id,
+                agent: "build",
+                model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
+                noReply: true,
+                parts: [{ type: "text", text: "hello" }],
+              })
+              if (other.info.role !== "user") throw new Error("expected user message")
+              expect(other.info.model.variant).toBeUndefined()
+
+              const match = yield* prompt.prompt({
+                sessionID: session.id,
+                agent: "build",
+                noReply: true,
+                parts: [{ type: "text", text: "hello again" }],
+              })
+              if (match.info.role !== "user") throw new Error("expected user message")
+              expect(match.info.model).toEqual({
+                providerID: ProviderID.make("openai"),
+                modelID: ModelID.make("gpt-5.2"),
+                variant: "xhigh",
+              })
+              expect(match.info.model.variant).toBe("xhigh")
+
+              const override = yield* prompt.prompt({
+                sessionID: session.id,
+                agent: "build",
+                noReply: true,
+                variant: "high",
+                parts: [{ type: "text", text: "hello third" }],
+              })
+              if (override.info.role !== "user") throw new Error("expected user message")
+              expect(override.info.model.variant).toBe("high")
+
+              yield* sessions.remove(session.id)
+            }),
+          ),
       })
     } finally {
       if (prev === undefined) delete process.env.OPENAI_API_KEY
@@ -451,24 +492,33 @@ describe("session.agent-resolution", () => {
     await using tmp = await tmpdir({ git: true })
     await Instance.provide({
       directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const err = await SessionPrompt.prompt({
-          sessionID: session.id,
-          agent: "nonexistent-agent-xyz",
-          noReply: true,
-          parts: [{ type: "text", text: "hello" }],
-        }).then(
-          () => undefined,
-          (e) => e,
-        )
-        expect(err).toBeDefined()
-        expect(err).not.toBeInstanceOf(TypeError)
-        expect(NamedError.Unknown.isInstance(err)).toBe(true)
-        if (NamedError.Unknown.isInstance(err)) {
-          expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
-        }
-      },
+      fn: () =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({})
+            const err = yield* Effect.promise(() =>
+              Effect.runPromise(
+                prompt.prompt({
+                  sessionID: session.id,
+                  agent: "nonexistent-agent-xyz",
+                  noReply: true,
+                  parts: [{ type: "text", text: "hello" }],
+                }),
+              ).then(
+                () => undefined,
+                (e) => e,
+              ),
+            )
+            expect(err).toBeDefined()
+            expect(err).not.toBeInstanceOf(TypeError)
+            expect(NamedError.Unknown.isInstance(err)).toBe(true)
+            if (NamedError.Unknown.isInstance(err)) {
+              expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
+            }
+          }),
+        ),
     })
   }, 30000)
 
@@ -476,22 +526,31 @@ describe("session.agent-resolution", () => {
     await using tmp = await tmpdir({ git: true })
     await Instance.provide({
       directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const err = await SessionPrompt.prompt({
-          sessionID: session.id,
-          agent: "nonexistent-agent-xyz",
-          noReply: true,
-          parts: [{ type: "text", text: "hello" }],
-        }).then(
-          () => undefined,
-          (e) => e,
-        )
-        expect(NamedError.Unknown.isInstance(err)).toBe(true)
-        if (NamedError.Unknown.isInstance(err)) {
-          expect(err.data.message).toContain("build")
-        }
-      },
+      fn: () =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({})
+            const err = yield* Effect.promise(() =>
+              Effect.runPromise(
+                prompt.prompt({
+                  sessionID: session.id,
+                  agent: "nonexistent-agent-xyz",
+                  noReply: true,
+                  parts: [{ type: "text", text: "hello" }],
+                }),
+              ).then(
+                () => undefined,
+                (e) => e,
+              ),
+            )
+            expect(NamedError.Unknown.isInstance(err)).toBe(true)
+            if (NamedError.Unknown.isInstance(err)) {
+              expect(err.data.message).toContain("build")
+            }
+          }),
+        ),
     })
   }, 30000)
 
@@ -499,24 +558,33 @@ describe("session.agent-resolution", () => {
     await using tmp = await tmpdir({ git: true })
     await Instance.provide({
       directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const err = await SessionPrompt.command({
-          sessionID: session.id,
-          command: "nonexistent-command-xyz",
-          arguments: "",
-        }).then(
-          () => undefined,
-          (e) => e,
-        )
-        expect(err).toBeDefined()
-        expect(err).not.toBeInstanceOf(TypeError)
-        expect(NamedError.Unknown.isInstance(err)).toBe(true)
-        if (NamedError.Unknown.isInstance(err)) {
-          expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
-          expect(err.data.message).toContain("init")
-        }
-      },
+      fn: () =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({})
+            const err = yield* Effect.promise(() =>
+              Effect.runPromise(
+                prompt.command({
+                  sessionID: session.id,
+                  command: "nonexistent-command-xyz",
+                  arguments: "",
+                }),
+              ).then(
+                () => undefined,
+                (e) => e,
+              ),
+            )
+            expect(err).toBeDefined()
+            expect(err).not.toBeInstanceOf(TypeError)
+            expect(NamedError.Unknown.isInstance(err)).toBe(true)
+            if (NamedError.Unknown.isInstance(err)) {
+              expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
+              expect(err.data.message).toContain("init")
+            }
+          }),
+        ),
     })
   }, 30000)
 })

+ 191 - 160
packages/opencode/test/session/structured-output-integration.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import path from "path"
+import { Effect, Layer } from "effect"
 import { Session } from "../../src/session"
 import { SessionPrompt } from "../../src/session/prompt"
 import { Log } from "../../src/util/log"
@@ -20,51 +21,63 @@ async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
   })
 }
 
+function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
+  return Effect.runPromise(
+    fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
+  )
+}
+
 describe("StructuredOutput Integration", () => {
   test.skipIf(!hasApiKey)(
     "produces structured output with simple schema",
     async () => {
-      await withInstance(async () => {
-        const session = await Session.create({ title: "Structured Output Test" })
-
-        const result = await SessionPrompt.prompt({
-          sessionID: session.id,
-          parts: [
-            {
-              type: "text",
-              text: "What is 2 + 2? Provide a simple answer.",
-            },
-          ],
-          format: {
-            type: "json_schema",
-            schema: {
-              type: "object",
-              properties: {
-                answer: { type: "number", description: "The numerical answer" },
-                explanation: { type: "string", description: "Brief explanation" },
+      await withInstance(() =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({ title: "Structured Output Test" })
+
+            const result = yield* prompt.prompt({
+              sessionID: session.id,
+              parts: [
+                {
+                  type: "text",
+                  text: "What is 2 + 2? Provide a simple answer.",
+                },
+              ],
+              format: {
+                type: "json_schema",
+                schema: {
+                  type: "object",
+                  properties: {
+                    answer: { type: "number", description: "The numerical answer" },
+                    explanation: { type: "string", description: "Brief explanation" },
+                  },
+                  required: ["answer"],
+                },
+                retryCount: 0,
               },
-              required: ["answer"],
-            },
-            retryCount: 0,
-          },
-        })
-
-        // Verify structured output was captured (only on assistant messages)
-        expect(result.info.role).toBe("assistant")
-        if (result.info.role === "assistant") {
-          expect(result.info.structured).toBeDefined()
-          expect(typeof result.info.structured).toBe("object")
-
-          const output = result.info.structured as any
-          expect(output.answer).toBe(4)
-
-          // Verify no error was set
-          expect(result.info.error).toBeUndefined()
-        }
-
-        // Clean up
-        // Note: Not removing session to avoid race with background SessionSummary.summarize
-      })
+            })
+
+            // Verify structured output was captured (only on assistant messages)
+            expect(result.info.role).toBe("assistant")
+            if (result.info.role === "assistant") {
+              expect(result.info.structured).toBeDefined()
+              expect(typeof result.info.structured).toBe("object")
+
+              const output = result.info.structured as any
+              expect(output.answer).toBe(4)
+
+              // Verify no error was set
+              expect(result.info.error).toBeUndefined()
+            }
+
+            // Clean up
+            // Note: Not removing session to avoid race with background SessionSummary.summarize
+          }),
+        ),
+      )
     },
     60000,
   )
@@ -72,62 +85,68 @@ describe("StructuredOutput Integration", () => {
   test.skipIf(!hasApiKey)(
     "produces structured output with nested objects",
     async () => {
-      await withInstance(async () => {
-        const session = await Session.create({ title: "Nested Schema Test" })
-
-        const result = await SessionPrompt.prompt({
-          sessionID: session.id,
-          parts: [
-            {
-              type: "text",
-              text: "Tell me about Anthropic company in a structured format.",
-            },
-          ],
-          format: {
-            type: "json_schema",
-            schema: {
-              type: "object",
-              properties: {
-                company: {
+      await withInstance(() =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({ title: "Nested Schema Test" })
+
+            const result = yield* prompt.prompt({
+              sessionID: session.id,
+              parts: [
+                {
+                  type: "text",
+                  text: "Tell me about Anthropic company in a structured format.",
+                },
+              ],
+              format: {
+                type: "json_schema",
+                schema: {
                   type: "object",
                   properties: {
-                    name: { type: "string" },
-                    founded: { type: "number" },
+                    company: {
+                      type: "object",
+                      properties: {
+                        name: { type: "string" },
+                        founded: { type: "number" },
+                      },
+                      required: ["name", "founded"],
+                    },
+                    products: {
+                      type: "array",
+                      items: { type: "string" },
+                    },
                   },
-                  required: ["name", "founded"],
-                },
-                products: {
-                  type: "array",
-                  items: { type: "string" },
+                  required: ["company"],
                 },
+                retryCount: 0,
               },
-              required: ["company"],
-            },
-            retryCount: 0,
-          },
-        })
-
-        // Verify structured output was captured (only on assistant messages)
-        expect(result.info.role).toBe("assistant")
-        if (result.info.role === "assistant") {
-          expect(result.info.structured).toBeDefined()
-          const output = result.info.structured as any
-
-          expect(output.company).toBeDefined()
-          expect(output.company.name).toBe("Anthropic")
-          expect(typeof output.company.founded).toBe("number")
-
-          if (output.products) {
-            expect(Array.isArray(output.products)).toBe(true)
-          }
-
-          // Verify no error was set
-          expect(result.info.error).toBeUndefined()
-        }
-
-        // Clean up
-        // Note: Not removing session to avoid race with background SessionSummary.summarize
-      })
+            })
+
+            // Verify structured output was captured (only on assistant messages)
+            expect(result.info.role).toBe("assistant")
+            if (result.info.role === "assistant") {
+              expect(result.info.structured).toBeDefined()
+              const output = result.info.structured as any
+
+              expect(output.company).toBeDefined()
+              expect(output.company.name).toBe("Anthropic")
+              expect(typeof output.company.founded).toBe("number")
+
+              if (output.products) {
+                expect(Array.isArray(output.products)).toBe(true)
+              }
+
+              // Verify no error was set
+              expect(result.info.error).toBeUndefined()
+            }
+
+            // Clean up
+            // Note: Not removing session to avoid race with background SessionSummary.summarize
+          }),
+        ),
+      )
     },
     60000,
   )
@@ -135,35 +154,41 @@ describe("StructuredOutput Integration", () => {
   test.skipIf(!hasApiKey)(
     "works with text outputFormat (default)",
     async () => {
-      await withInstance(async () => {
-        const session = await Session.create({ title: "Text Output Test" })
-
-        const result = await SessionPrompt.prompt({
-          sessionID: session.id,
-          parts: [
-            {
-              type: "text",
-              text: "Say hello.",
-            },
-          ],
-          format: {
-            type: "text",
-          },
-        })
-
-        // Verify no structured output (text mode) and no error
-        expect(result.info.role).toBe("assistant")
-        if (result.info.role === "assistant") {
-          expect(result.info.structured).toBeUndefined()
-          expect(result.info.error).toBeUndefined()
-        }
-
-        // Verify we got a response with parts
-        expect(result.parts.length).toBeGreaterThan(0)
-
-        // Clean up
-        // Note: Not removing session to avoid race with background SessionSummary.summarize
-      })
+      await withInstance(() =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({ title: "Text Output Test" })
+
+            const result = yield* prompt.prompt({
+              sessionID: session.id,
+              parts: [
+                {
+                  type: "text",
+                  text: "Say hello.",
+                },
+              ],
+              format: {
+                type: "text",
+              },
+            })
+
+            // Verify no structured output (text mode) and no error
+            expect(result.info.role).toBe("assistant")
+            if (result.info.role === "assistant") {
+              expect(result.info.structured).toBeUndefined()
+              expect(result.info.error).toBeUndefined()
+            }
+
+            // Verify we got a response with parts
+            expect(result.parts.length).toBeGreaterThan(0)
+
+            // Clean up
+            // Note: Not removing session to avoid race with background SessionSummary.summarize
+          }),
+        ),
+      )
     },
     60000,
   )
@@ -171,47 +196,53 @@ describe("StructuredOutput Integration", () => {
   test.skipIf(!hasApiKey)(
     "stores outputFormat on user message",
     async () => {
-      await withInstance(async () => {
-        const session = await Session.create({ title: "OutputFormat Storage Test" })
-
-        await SessionPrompt.prompt({
-          sessionID: session.id,
-          parts: [
-            {
-              type: "text",
-              text: "What is 1 + 1?",
-            },
-          ],
-          format: {
-            type: "json_schema",
-            schema: {
-              type: "object",
-              properties: {
-                result: { type: "number" },
+      await withInstance(() =>
+        run(
+          Effect.gen(function* () {
+            const prompt = yield* SessionPrompt.Service
+            const sessions = yield* Session.Service
+            const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
+
+            yield* prompt.prompt({
+              sessionID: session.id,
+              parts: [
+                {
+                  type: "text",
+                  text: "What is 1 + 1?",
+                },
+              ],
+              format: {
+                type: "json_schema",
+                schema: {
+                  type: "object",
+                  properties: {
+                    result: { type: "number" },
+                  },
+                  required: ["result"],
+                },
+                retryCount: 3,
               },
-              required: ["result"],
-            },
-            retryCount: 3,
-          },
-        })
-
-        // Get all messages from session
-        const messages = await Session.messages({ sessionID: session.id })
-        const userMessage = messages.find((m) => m.info.role === "user")
-
-        // Verify outputFormat was stored on user message
-        expect(userMessage).toBeDefined()
-        if (userMessage?.info.role === "user") {
-          expect(userMessage.info.format).toBeDefined()
-          expect(userMessage.info.format?.type).toBe("json_schema")
-          if (userMessage.info.format?.type === "json_schema") {
-            expect(userMessage.info.format.retryCount).toBe(3)
-          }
-        }
-
-        // Clean up
-        // Note: Not removing session to avoid race with background SessionSummary.summarize
-      })
+            })
+
+            // Get all messages from session
+            const messages = yield* sessions.messages({ sessionID: session.id })
+            const userMessage = messages.find((m) => m.info.role === "user")
+
+            // Verify outputFormat was stored on user message
+            expect(userMessage).toBeDefined()
+            if (userMessage?.info.role === "user") {
+              expect(userMessage.info.format).toBeDefined()
+              expect(userMessage.info.format?.type).toBe("json_schema")
+              if (userMessage.info.format?.type === "json_schema") {
+                expect(userMessage.info.format.retryCount).toBe(3)
+              }
+            }
+
+            // Clean up
+            // Note: Not removing session to avoid race with background SessionSummary.summarize
+          }),
+        ),
+      )
     },
     60000,
   )

+ 1 - 1
packages/opencode/test/tool/task.test.ts

@@ -76,7 +76,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
   }
 }
 
-function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
+function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts {
   const id = MessageID.ascending()
   return {
     info: {