refactor(session): remove prompt async facade exports

This commit is contained in:
Kit Langton 2026-04-13 23:03:06 -04:00
commit 2fc5b00537
8 changed files with 605 additions and 546 deletions

View file

@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git" import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Effect } from "effect"
type GitHubAuthor = { type GitHubAuthor = {
login: string login: string
@ -937,96 +938,86 @@ export const GithubRunCommand = cmd({
async function chat(message: string, files: PromptFiles = []) { async function chat(message: string, files: PromptFiles = []) {
console.log("Sending message to opencode...") console.log("Sending message to opencode...")
const result = await SessionPrompt.prompt({ return AppRuntime.runPromise(
sessionID: session.id, Effect.gen(function* () {
messageID: MessageID.ascending(), const prompt = yield* SessionPrompt.Service
variant, const result = yield* prompt.prompt({
model: { sessionID: session.id,
providerID, messageID: MessageID.ascending(),
modelID, variant,
}, model: {
// agent is omitted - server will use default_agent from config or fall back to "build" providerID,
parts: [ modelID,
{
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,
},
}, },
]), // 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) {
if (result.info.role === "assistant" && result.info.error) { const err = result.info.error
const err = result.info.error console.error("Agent error:", err)
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") { const text = extractResponseText(result.parts)
throw new Error(formatPromptTooLargeError(files)) if (text) return text
}
const errorMsg = err.data?.message || "" console.log("Requesting summary from agent...")
throw new Error(`${err.name}: ${errorMsg}`) 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 (summary.info.role === "assistant" && summary.info.error) {
if (text) return text 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 const summaryText = extractResponseText(summary.parts)
console.log("Requesting summary from agent...") if (!summaryText) throw new Error("Failed to get summary from agent")
const summary = await SessionPrompt.prompt({ return summaryText
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
} }
async function getOidcToken() { async function getOidcToken() {

View file

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

View file

@ -46,7 +46,6 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool, type TaskPromptOps } from "@/tool/task" import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state" 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({ export const PromptInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
messageID: MessageID.zod.optional(), 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 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({ export const LoopInput = z.object({
sessionID: SessionID.zod, 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({ export const ShellInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
messageID: MessageID.zod.optional(), 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 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({ export const CommandInput = z.object({
messageID: MessageID.zod.optional(), messageID: MessageID.zod.optional(),
sessionID: SessionID.zod, 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 type CommandInput = z.infer<typeof CommandInput>
export async function command(input: CommandInput) {
return runPromise((svc) => svc.command(CommandInput.parse(input)))
}
/** @internal Exported for testing */ /** @internal Exported for testing */
export function createStructuredOutputTool(input: { export function createStructuredOutputTool(input: {
schema: Record<string, any> schema: Record<string, any>

View file

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

View file

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

View file

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

View file

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

View file

@ -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() const id = MessageID.ascending()
return { return {
info: { info: {