From 2fc5b00537105ef000dd8203336f8f96b6f7e5d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 23:03:06 -0400 Subject: [PATCH] refactor(session): remove prompt async facade exports --- packages/opencode/src/cli/cmd/github.ts | 159 +++--- .../opencode/src/server/instance/session.ts | 30 +- packages/opencode/src/session/prompt.ts | 27 - .../test/server/session-actions.test.ts | 8 +- .../test/session/prompt-effect.test.ts | 74 ++- packages/opencode/test/session/prompt.test.ts | 466 ++++++++++-------- .../structured-output-integration.test.ts | 313 ++++++------ packages/opencode/test/tool/task.test.ts | 2 +- 8 files changed, 569 insertions(+), 510 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 9481bec5e5..fd375f55f9 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/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, }, - ]), - ], - }) + // 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, + }, + }, + ]), + ], + }) - // 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 (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 || ""}`) + } - if (err.name === "ContextOverflowError") { - throw new Error(formatPromptTooLargeError(files)) - } + const text = extractResponseText(result.parts) + if (text) return text - const errorMsg = err.data?.message || "" - throw new Error(`${err.name}: ${errorMsg}`) - } + 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 text = extractResponseText(result.parts) - if (text) return text + 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 || ""}`) + } - // 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) - - if (err.name === "ContextOverflowError") { - throw new Error(formatPromptTooLargeError(files)) - } - - const errorMsg = err.data?.message || "" - throw new Error(`${err.name}: ${errorMsg}`) - } - - const summaryText = extractResponseText(summary.parts) - if (!summaryText) { - throw new Error("Failed to get summary from agent") - } - - return summaryText + const summaryText = extractResponseText(summary.parts) + if (!summaryText) throw new Error("Failed to get summary from agent") + return summaryText + }), + ) } async function getOidcToken() { diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 145b2ccd39..663976eb40 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/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) }, ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1d96392b0a..4b0c30802b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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 - 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) { - 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 - 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 - export async function command(input: CommandInput) { - return runPromise((svc) => svc.command(CommandInput.parse(input))) - } - /** @internal Exported for testing */ export function createStructuredOutputTool(input: { schema: Record diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 29032c69cf..da65434acc 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/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) }, diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 98b1fde000..9523915bd9 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/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) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index bf7b99ef2e..c1d6f1da97 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/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(fx: Effect.Effect) { + return Effect.runPromise( + fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), + ) +} + function defer() { let resolve!: (value: T | PromiseLike) => void const promise = new Promise((done) => { @@ -104,34 +111,39 @@ describe("session.prompt missing file", () => { await Instance.provide({ directory: tmp.path, - fn: async () => { - const session = await Session.create({}) + 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 = 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", - }, - ], - }) + 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({}) + 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 = 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" }, - ], - }) + 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") + 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") + 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 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) + 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) - await Session.remove(session.id) - }, + 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?" }], - }) + 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) + 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) - }, + 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" }], - }) + 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" }], + }), + ) - await ready.promise - await SessionPrompt.cancel(session.id) + yield* Effect.promise(() => ready.promise) + yield* prompt.cancel(session.id) - const result = await Promise.race([ - run, - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000), - ), - ]) + const result = yield* Effect.promise(() => + Promise.race([ + task, + new Promise((_, 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") - } + 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") - } - }, + 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({}) + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.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 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 = 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 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 = 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") + 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") - await Session.remove(session.id) - }, + 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) }) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index c9c5436569..64266de47a 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/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(fn: () => Promise): Promise { }) } +function run(fx: Effect.Effect) { + 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" }) + 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 = 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" }, + 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") + // 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) + const output = result.info.structured as any + expect(output.answer).toBe(4) - // Verify no error was set - expect(result.info.error).toBeUndefined() - } + // Verify no error was set + expect(result.info.error).toBeUndefined() + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }) + // 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" }) + 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 = 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: { + 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 + // 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") + 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) - } + if (output.products) { + expect(Array.isArray(output.products)).toBe(true) + } - // Verify no error was set - expect(result.info.error).toBeUndefined() - } + // Verify no error was set + expect(result.info.error).toBeUndefined() + } - // Clean up - // Note: Not removing session to avoid race with background SessionSummary.summarize - }) + // 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" }) + 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 = await SessionPrompt.prompt({ - sessionID: session.id, - parts: [ - { - type: "text", - text: "Say hello.", - }, - ], - format: { - type: "text", - }, - }) + 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 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) + // 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 - }) + // 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 withInstance(() => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.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" }, + 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") + // 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) - } - } + // 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 - }) + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }), + ), + ) }, 60000, ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 436c46490b..e7a143c9af 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -76,7 +76,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; } } -function reply(input: Parameters[0], text: string): MessageV2.WithParts { +function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts { const id = MessageID.ascending() return { info: {