diff options
| author | Kit Langton <[email protected]> | 2026-04-14 13:45:13 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-14 13:45:13 -0400 |
| commit | 68384613be9b3869760064521ad5d0f3ba30062a (patch) | |
| tree | cc99f0f43cdb14181034c3be77764fbcad15b47b /packages | |
| parent | 4f967d5bc05d545fde9deccdb632276ec642722e (diff) | |
| download | opencode-68384613be9b3869760064521ad5d0f3ba30062a.tar.gz opencode-68384613be9b3869760064521ad5d0f3ba30062a.zip | |
refactor(session): remove async facade exports (#22471)
Diffstat (limited to 'packages')
24 files changed, 1163 insertions, 971 deletions
diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index ef6117633..2b89df838 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -33,30 +33,35 @@ const seed = async () => { }), ) - const session = await Session.create({ title }) - const messageID = MessageID.ascending() - const partID = PartID.ascending() - const message = { - id: messageID, - sessionID: session.id, - role: "user" as const, - time: { created: now }, - agent: "build", - model: { - providerID: ProviderID.make(providerID), - modelID: ModelID.make(modelID), - }, - } - const part = { - id: partID, - sessionID: session.id, - messageID, - type: "text" as const, - text, - time: { start: now }, - } - await Session.updateMessage(message) - await Session.updatePart(part) + await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const result = yield* session.create({ title }) + const messageID = MessageID.ascending() + const partID = PartID.ascending() + const message = { + id: messageID, + sessionID: result.id, + role: "user" as const, + time: { created: now }, + agent: "build", + model: { + providerID: ProviderID.make(providerID), + modelID: ModelID.make(modelID), + }, + } + const part = { + id: partID, + sessionID: result.id, + messageID, + type: "text" as const, + text, + time: { start: now }, + } + yield* session.updateMessage(message) + yield* session.updatePart(part) + }), + ) await AppRuntime.runPromise( Project.Service.use((svc) => svc.update({ projectID: Instance.project.id, name: "E2E Project" })), ) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 51de43f67..ea45cde66 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -123,45 +123,49 @@ function parseToolParams(input?: string) { } async function createToolContext(agent: Agent.Info) { - const session = await Session.create({ title: `Debug tool run (${agent.name})` }) - const messageID = MessageID.ascending() - const model = - agent.model ?? - (await AppRuntime.runPromise( - Effect.gen(function* () { - const provider = yield* Provider.Service - return yield* provider.defaultModel() - }), - )) - const now = Date.now() - const message: MessageV2.Assistant = { - id: messageID, - sessionID: session.id, - role: "assistant", - time: { - created: now, - }, - parentID: messageID, - modelID: model.modelID, - providerID: model.providerID, - mode: "debug", - agent: agent.name, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - } - await Session.updateMessage(message) + const { session, messageID } = await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const result = yield* session.create({ title: `Debug tool run (${agent.name})` }) + const messageID = MessageID.ascending() + const model = agent.model + ? agent.model + : yield* Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.defaultModel() + }) + const now = Date.now() + const message: MessageV2.Assistant = { + id: messageID, + sessionID: result.id, + role: "assistant", + time: { + created: now, + }, + parentID: messageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "debug", + agent: agent.name, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + } + yield* session.updateMessage(message) + return { session: result, messageID } + }), + ) const ruleset = Permission.merge(agent.permission, session.permission ?? []) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 4088b4818..cd2637722 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -6,6 +6,8 @@ import { bootstrap } from "../bootstrap" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" export const ExportCommand = cmd({ command: "export [sessionID]", @@ -67,8 +69,16 @@ export const ExportCommand = cmd({ } try { - const sessionInfo = await Session.get(sessionID!) - const messages = await Session.messages({ sessionID: sessionInfo.id }) + const { sessionInfo, messages } = await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const sessionInfo = yield* session.get(sessionID!) + return { + sessionInfo, + messages: yield* session.messages({ sessionID: sessionInfo.id }), + } + }), + ) const exportData = { info: sessionInfo, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 9481bec5e..074d9e518 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 @@ -551,15 +552,19 @@ export const GithubRunCommand = cmd({ // Setup opencode session const repoData = await fetchRepo() - session = await Session.create({ - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }) + session = await AppRuntime.runPromise( + Session.Service.use((svc) => + svc.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }), + ), + ) subscribeSessionEvents() shareId = await (async () => { if (share === false) return @@ -937,96 +942,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() { diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 8acd7480c..6f79e726f 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -11,6 +11,7 @@ import { Process } from "../../util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" +import { AppRuntime } from "@/effect/app-runtime" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -60,12 +61,12 @@ export const SessionDeleteCommand = cmd({ await bootstrap(process.cwd(), async () => { const sessionID = SessionID.make(args.sessionID) try { - await Session.get(sessionID) + await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) } catch { UI.error(`Session not found: ${args.sessionID}`) process.exit(1) } - await Session.remove(sessionID) + await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) }) }, diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 04c1fe2eb..527a6ac95 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -6,6 +6,7 @@ import { Database } from "../../storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "../../project/project" import { Instance } from "../../project/instance" +import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { totalSessions: number @@ -167,7 +168,9 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin const batch = filteredSessions.slice(i, i + BATCH_SIZE) const batchPromises = batch.map(async (session) => { - const messages = await Session.messages({ sessionID: session.id }) + const messages = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.messages({ sessionID: session.id })), + ) let sessionCost = 0 let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 868131eb8..9155ad451 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -41,7 +41,7 @@ async function getSessionWorkspace(url: URL) { const id = getSessionID(url) if (!id) return null - const session = await Session.get(id).catch(() => undefined) + const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined) return session?.workspaceID } diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 145b2ccd3..e443a6ddd 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -121,12 +121,12 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.get.schema, + sessionID: Session.GetInput, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID - const session = await Session.get(sessionID) + const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) return c.json(session) }, ) @@ -152,12 +152,12 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.children.schema, + sessionID: Session.ChildrenInput, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID - const session = await Session.children(sessionID) + const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID))) return c.json(session) }, ) @@ -209,7 +209,7 @@ export const SessionRoutes = lazy(() => }, }, }), - validator("json", Session.create.schema), + validator("json", Session.CreateInput), async (c) => { const body = c.req.valid("json") ?? {} const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body))) @@ -237,12 +237,12 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.remove.schema, + sessionID: Session.RemoveInput, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.remove(sessionID) + await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) return c.json(true) }, ) @@ -285,22 +285,27 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID const updates = c.req.valid("json") - const current = await Session.get(sessionID) + const session = await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const current = yield* session.get(sessionID) - if (updates.title !== undefined) { - await Session.setTitle({ sessionID, title: updates.title }) - } - if (updates.permission !== undefined) { - await Session.setPermission({ - sessionID, - permission: Permission.merge(current.permission ?? [], updates.permission), - }) - } - if (updates.time?.archived !== undefined) { - await Session.setArchived({ sessionID, time: updates.time.archived }) - } + if (updates.title !== undefined) { + yield* session.setTitle({ sessionID, title: updates.title }) + } + if (updates.permission !== undefined) { + yield* session.setPermission({ + sessionID, + permission: Permission.merge(current.permission ?? [], updates.permission), + }) + } + if (updates.time?.archived !== undefined) { + yield* session.setArchived({ sessionID, time: updates.time.archived }) + } - const session = await Session.get(sessionID) + return yield* session.get(sessionID) + }), + ) return c.json(session) }, ) @@ -341,13 +346,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) }, ) @@ -371,14 +380,14 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.fork.schema.shape.sessionID, + sessionID: Session.ForkInput.shape.sessionID, }), ), - validator("json", Session.fork.schema.omit({ sessionID: true })), + validator("json", Session.ForkInput.omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - const result = await Session.fork({ ...body, sessionID }) + const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID }))) return c.json(result) }, ) @@ -407,7 +416,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) }, ) @@ -657,15 +666,14 @@ export const SessionRoutes = lazy(() => async (c) => { const query = c.req.valid("query") const sessionID = c.req.valid("param").sessionID - if (query.limit === undefined) { - await Session.get(sessionID) - const messages = await Session.messages({ sessionID }) - return c.json(messages) - } - - if (query.limit === 0) { - await Session.get(sessionID) - const messages = await Session.messages({ sessionID }) + if (query.limit === undefined || query.limit === 0) { + const messages = await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + yield* session.get(sessionID) + return yield* session.messages({ sessionID }) + }), + ) return c.json(messages) } @@ -793,11 +801,15 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - await Session.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }) + await AppRuntime.runPromise( + Session.Service.use((svc) => + svc.removePart({ + sessionID: params.sessionID, + messageID: params.messageID, + partID: params.partID, + }), + ), + ) return c.json(true) }, ) @@ -835,7 +847,7 @@ export const SessionRoutes = lazy(() => `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, ) } - const part = await Session.updatePart(body) + const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body))) return c.json(part) }, ) @@ -875,7 +887,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 +918,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 +962,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 +994,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/server/instance/tui.ts b/packages/opencode/src/server/instance/tui.ts index 8650a0ccc..13f150655 100644 --- a/packages/opencode/src/server/instance/tui.ts +++ b/packages/opencode/src/server/instance/tui.ts @@ -4,6 +4,7 @@ import z from "zod" import { Bus } from "../../bus" import { Session } from "../../session" import { TuiEvent } from "@/cli/cmd/tui/event" +import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "../../util/queue" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -370,7 +371,7 @@ export const TuiRoutes = lazy(() => validator("json", TuiEvent.SessionSelect.properties), async (c) => { const { sessionID } = c.req.valid("json") - await Session.get(sessionID) + await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) await Bus.publish(TuiEvent.SessionSelect, { sessionID }) return c.json(true) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b43b724a0..aa3404ad7 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -19,7 +19,6 @@ import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state" -import { fn } from "@/util/fn" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" @@ -29,7 +28,6 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context } from "effect" -import { makeRuntime } from "@/effect/run-service" export namespace Session { const log = Log.create({ service: "session" }) @@ -179,6 +177,30 @@ export namespace Session { }) export type GlobalInfo = z.output<typeof GlobalInfo> + export const CreateInput = z + .object({ + parentID: SessionID.zod.optional(), + title: z.string().optional(), + permission: Info.shape.permission, + workspaceID: WorkspaceID.zod.optional(), + }) + .optional() + export type CreateInput = z.output<typeof CreateInput> + + export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }) + export const GetInput = SessionID.zod + export const ChildrenInput = SessionID.zod + export const RemoveInput = SessionID.zod + export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() }) + export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() }) + export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }) + export const SetRevertInput = z.object({ + sessionID: SessionID.zod, + revert: Info.shape.revert, + summary: Info.shape.summary, + }) + export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() }) + export const Event = { Created: SyncEvent.define({ type: "session.created", @@ -682,48 +704,6 @@ export namespace Session { export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export const create = fn( - z - .object({ - parentID: SessionID.zod.optional(), - title: z.string().optional(), - permission: Info.shape.permission, - workspaceID: WorkspaceID.zod.optional(), - }) - .optional(), - (input) => runPromise((svc) => svc.create(input)), - ) - - export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) => - runPromise((svc) => svc.fork(input)), - ) - - export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id))) - - export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) => - runPromise((svc) => svc.setTitle(input)), - ) - - export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) => - runPromise((svc) => svc.setArchived(input)), - ) - - export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) => - runPromise((svc) => svc.setPermission(input)), - ) - - export const setRevert = fn( - z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }), - (input) => - runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })), - ) - - export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) => - runPromise((svc) => svc.messages(input)), - ) - export function* list(input?: { directory?: string workspaceID?: WorkspaceID @@ -835,25 +815,4 @@ export namespace Session { yield { ...fromRow(row), project } } } - - export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id))) - export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id))) - export async function updateMessage<T extends MessageV2.Info>(msg: T): Promise<T> { - MessageV2.Info.parse(msg) - return runPromise((svc) => svc.updateMessage(msg)) - } - - export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) => - runPromise((svc) => svc.removeMessage(input)), - ) - - export const removePart = fn( - z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }), - (input) => runPromise((svc) => svc.removePart(input)), - ) - - export async function updatePart<T extends MessageV2.Part>(part: T): Promise<T> { - MessageV2.Part.parse(part) - return runPromise((svc) => svc.updatePart(part)) - } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 749211737..4b0c30802 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 { attach, makeRuntime } from "@/effect/run-service" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" @@ -108,9 +107,8 @@ export namespace SessionPrompt { const run = { promise: <A, E>(effect: Effect.Effect<A, E>) => - Effect.runPromise(attach(effect).pipe(Effect.provide(EffectLogger.layer))), - fork: <A, E>(effect: Effect.Effect<A, E>) => - Effect.runFork(attach(effect).pipe(Effect.provide(EffectLogger.layer))), + Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))), + fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))), } const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { @@ -1709,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(), @@ -1778,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(), @@ -1812,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, @@ -1839,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> diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 0030e3c84..08210de8a 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -8,7 +8,7 @@ import { ShareNext } from "./share-next" export namespace SessionShare { export interface Interface { - readonly create: (input?: Parameters<typeof Session.create>[0]) => Effect.Effect<Session.Info> + readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info> readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown> readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown> } @@ -38,7 +38,7 @@ export namespace SessionShare { yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) }) - const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) { + const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { const result = yield* session.create(input) if (result.parentID) return result const conf = yield* cfg.get() diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 05d6de04b..a1e374b4f 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,27 +1,43 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import z from "zod" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" -import { Session } from "../../src/session" +import { Session as SessionNs } from "../../src/session" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) -describe("Session.listGlobal", () => { +function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + setArchived(input: z.output<typeof SessionNs.SetArchivedInput>) { + return run(SessionNs.Service.use((svc) => svc.setArchived(input))) + }, +} + +describe("session.listGlobal", () => { test("lists sessions across projects with project metadata", async () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) const firstSession = await Instance.provide({ directory: first.path, - fn: async () => Session.create({ title: "first-session" }), + fn: async () => svc.create({ title: "first-session" }), }) const secondSession = await Instance.provide({ directory: second.path, - fn: async () => Session.create({ title: "second-session" }), + fn: async () => svc.create({ title: "second-session" }), }) - const sessions = [...Session.listGlobal({ limit: 200 })] + const sessions = [...svc.listGlobal({ limit: 200 })] const ids = sessions.map((session) => session.id) expect(ids).toContain(firstSession.id) @@ -44,20 +60,20 @@ describe("Session.listGlobal", () => { const archived = await Instance.provide({ directory: tmp.path, - fn: async () => Session.create({ title: "archived-session" }), + fn: async () => svc.create({ title: "archived-session" }), }) await Instance.provide({ directory: tmp.path, - fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }), + fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }), }) - const sessions = [...Session.listGlobal({ limit: 200 })] + const sessions = [...svc.listGlobal({ limit: 200 })] const ids = sessions.map((session) => session.id) expect(ids).not.toContain(archived.id) - const allSessions = [...Session.listGlobal({ limit: 200, archived: true })] + const allSessions = [...svc.listGlobal({ limit: 200, archived: true })] const allIds = allSessions.map((session) => session.id) expect(allIds).toContain(archived.id) @@ -68,19 +84,19 @@ describe("Session.listGlobal", () => { const first = await Instance.provide({ directory: tmp.path, - fn: async () => Session.create({ title: "page-one" }), + fn: async () => svc.create({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) const second = await Instance.provide({ directory: tmp.path, - fn: async () => Session.create({ title: "page-two" }), + fn: async () => svc.create({ title: "page-two" }), }) - const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })] + const page = [...svc.listGlobal({ directory: tmp.path, limit: 1 })] expect(page.length).toBe(1) expect(page[0].id).toBe(second.id) - const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })] + const next = [...svc.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })] const ids = next.map((session) => session.id) expect(ids).toContain(first.id) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 29032c69c..301691ae2 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,36 +1,48 @@ -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 { Server } from "../../src/server/server" -import { Session } from "../../src/session" -import { SessionPrompt } from "../../src/session/prompt" +import { Session as SessionNs } from "../../src/session" +import type { SessionID } from "../../src/session/schema" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + remove(id: SessionID) { + return run(SessionNs.Service.use((svc) => svc.remove(id))) + }, +} + afterEach(async () => { mock.restore() await Instance.disposeAll() }) 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 session = await svc.create({}) 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) + await svc.remove(session.id) }, }) }) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 933b5b5b5..8c86dc2f0 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,30 +1,42 @@ import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" import { Instance } from "../../src/project/instance" -import { Session } from "../../src/session" +import { Session as SessionNs } from "../../src/session" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, +} + afterEach(async () => { await Instance.disposeAll() }) -describe("Session.list", () => { +describe("session.list", () => { test("filters by directory", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { - const first = await Session.create({}) + const first = await svc.create({}) await using other = await tmpdir({ git: true }) const second = await Instance.provide({ directory: other.path, - fn: async () => Session.create({}), + fn: async () => svc.create({}), }) - const sessions = [...Session.list({ directory: tmp.path })] + const sessions = [...svc.list({ directory: tmp.path })] const ids = sessions.map((s) => s.id) expect(ids).toContain(first.id) @@ -38,10 +50,10 @@ describe("Session.list", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const root = await Session.create({ title: "root-session" }) - const child = await Session.create({ title: "child-session", parentID: root.id }) + const root = await svc.create({ title: "root-session" }) + const child = await svc.create({ title: "child-session", parentID: root.id }) - const sessions = [...Session.list({ roots: true })] + const sessions = [...svc.list({ roots: true })] const ids = sessions.map((s) => s.id) expect(ids).toContain(root.id) @@ -55,10 +67,10 @@ describe("Session.list", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({ title: "new-session" }) + const session = await svc.create({ title: "new-session" }) const futureStart = Date.now() + 86400000 - const sessions = [...Session.list({ start: futureStart })] + const sessions = [...svc.list({ start: futureStart })] expect(sessions.length).toBe(0) }, }) @@ -69,10 +81,10 @@ describe("Session.list", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Session.create({ title: "unique-search-term-abc" }) - await Session.create({ title: "other-session-xyz" }) + await svc.create({ title: "unique-search-term-abc" }) + await svc.create({ title: "other-session-xyz" }) - const sessions = [...Session.list({ search: "unique-search" })] + const sessions = [...svc.list({ search: "unique-search" })] const titles = sessions.map((s) => s.title) expect(titles).toContain("unique-search-term-abc") @@ -86,11 +98,11 @@ describe("Session.list", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Session.create({ title: "session-1" }) - await Session.create({ title: "session-2" }) - await Session.create({ title: "session-3" }) + await svc.create({ title: "session-1" }) + await svc.create({ title: "session-2" }) + await svc.create({ title: "session-3" }) - const sessions = [...Session.list({ limit: 2 })] + const sessions = [...svc.list({ limit: 2 })] expect(sessions.length).toBe(2) }, }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index fac336837..24ee6a1b4 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { Session } from "../../src/session" +import { Session as SessionNs } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { Log } from "../../src/util/log" @@ -9,6 +10,26 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + remove(id: SessionID) { + return run(SessionNs.Service.use((svc) => svc.remove(id))) + }, + updateMessage<T extends MessageV2.Info>(msg: T) { + return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) + }, + updatePart<T extends MessageV2.Part>(part: T) { + return run(SessionNs.Service.use((svc) => svc.updatePart(part))) + }, +} + afterEach(async () => { await Instance.disposeAll() }) @@ -30,7 +51,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D for (let i = 0; i < count; i++) { const id = MessageID.ascending() ids.push(id) - await Session.updateMessage({ + await svc.updateMessage({ id, sessionID, role: "user", @@ -40,7 +61,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D tools: {}, mode: "", } as unknown as MessageV2.Info) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID, messageID: id, @@ -58,7 +79,7 @@ describe("session messages endpoint", () => { Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 5) const app = Server.Default().app @@ -75,7 +96,7 @@ describe("session messages endpoint", () => { const bBody = (await b.json()) as MessageV2.WithParts[] expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) - await Session.remove(session.id) + await svc.remove(session.id) }, }), ) @@ -87,7 +108,7 @@ describe("session messages endpoint", () => { Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 3) const app = Server.Default().app @@ -96,7 +117,7 @@ describe("session messages endpoint", () => { const body = (await res.json()) as MessageV2.WithParts[] expect(body.map((item) => item.info.id)).toEqual(ids) - await Session.remove(session.id) + await svc.remove(session.id) }, }), ) @@ -108,7 +129,7 @@ describe("session messages endpoint", () => { Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const app = Server.Default().app const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`) @@ -117,7 +138,7 @@ describe("session messages endpoint", () => { const miss = await app.request(`/session/ses_missing/message?limit=2`) expect(miss.status).toBe(404) - await Session.remove(session.id) + await svc.remove(session.id) }, }), ) @@ -129,7 +150,7 @@ describe("session messages endpoint", () => { Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await fill(session.id, 520) const app = Server.Default().app @@ -138,7 +159,7 @@ describe("session messages endpoint", () => { const body = (await res.json()) as MessageV2.WithParts[] expect(body).toHaveLength(510) - await Session.remove(session.id) + await svc.remove(session.id) }, }), ) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 7558b4a6b..12552538d 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Session } from "../../src/session" +import { Effect } from "effect" +import { Session as SessionNs } from "../../src/session" +import type { SessionID } from "../../src/session/schema" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" @@ -7,6 +9,20 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + remove(id: SessionID) { + return run(SessionNs.Service.use((svc) => svc.remove(id))) + }, +} + afterEach(async () => { await Instance.disposeAll() }) @@ -18,7 +34,7 @@ describe("tui.selectSession endpoint", () => { directory: tmp.path, fn: async () => { // #given - const session = await Session.create({}) + const session = await svc.create({}) // #when const app = Server.Default().app @@ -33,7 +49,7 @@ describe("tui.selectSession endpoint", () => { const body = await response.json() expect(body).toBe(true) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 42511d211..ddfe85911 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -3,6 +3,7 @@ import { APICallError } from "ai" import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect" import * as Stream from "effect/Stream" import path from "path" +import z from "zod" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Agent } from "../../src/agent/agent" @@ -14,7 +15,7 @@ import { Log } from "../../src/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" -import { Session } from "../../src/session" +import { Session as SessionNs } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" @@ -29,6 +30,26 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" Log.init({ print: false }) +function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + messages(input: z.output<typeof SessionNs.MessagesInput>) { + return run(SessionNs.Service.use((svc) => svc.messages(input))) + }, + updateMessage<T extends MessageV2.Info>(msg: T) { + return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) + }, + updatePart<T extends MessageV2.Part>(part: T) { + return run(SessionNs.Service.use((svc) => svc.updatePart(part))) + }, +} + const summary = Layer.succeed( SessionSummary.Service, SessionSummary.Service.of({ @@ -80,7 +101,7 @@ function createModel(opts: { const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) async function user(sessionID: SessionID, text: string) { - const msg = await Session.updateMessage({ + const msg = await svc.updateMessage({ id: MessageID.ascending(), role: "user", sessionID, @@ -88,7 +109,7 @@ async function user(sessionID: SessionID, text: string) { model: ref, time: { created: Date.now() }, }) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID, @@ -119,12 +140,12 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string time: { created: Date.now() }, finish: "end_turn", } - await Session.updateMessage(msg) + await svc.updateMessage(msg) return msg } async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) { - return Session.updatePart({ + return svc.updatePart({ id: PartID.ascending(), messageID, sessionID, @@ -171,7 +192,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer, bus).pipe( Layer.provide(provider.layer), - Layer.provide(Session.defaultLayer), + Layer.provide(SessionNs.defaultLayer), Layer.provide(layer(result)), Layer.provide(Agent.defaultLayer), Layer.provide(plugin), @@ -191,9 +212,9 @@ const deps = Layer.mergeAll( ) const env = Layer.mergeAll( - Session.defaultLayer, + SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer, - SessionCompaction.layer.pipe(Layer.provide(Session.defaultLayer), Layer.provideMerge(deps)), + SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), ) const it = testEffect(env) @@ -227,7 +248,7 @@ function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fa return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( Layer.provide(provider.layer), - Layer.provide(Session.defaultLayer), + Layer.provide(SessionNs.defaultLayer), Layer.provide(Snapshot.defaultLayer), Layer.provide(layer), Layer.provide(Permission.defaultLayer), @@ -467,9 +488,9 @@ describe("session.compaction.create", () => { provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service - const session = yield* Session.Service + const ssn = yield* SessionNs.Service - const info = yield* session.create({}) + const info = yield* ssn.create({}) yield* compact.create({ sessionID: info.id, @@ -479,7 +500,7 @@ describe("session.compaction.create", () => { overflow: true, }) - const msgs = yield* session.messages({ sessionID: info.id }) + const msgs = yield* ssn.messages({ sessionID: info.id }) expect(msgs).toHaveLength(1) expect(msgs[0].info.role).toBe("user") expect(msgs[0].parts).toHaveLength(1) @@ -499,9 +520,9 @@ describe("session.compaction.prune", () => { provideTmpdirInstance((dir) => Effect.gen(function* () { const compact = yield* SessionCompaction.Service - const session = yield* Session.Service - const info = yield* session.create({}) - const a = yield* session.updateMessage({ + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const a = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, @@ -509,7 +530,7 @@ describe("session.compaction.prune", () => { model: ref, time: { created: Date.now() }, }) - yield* session.updatePart({ + yield* ssn.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: info.id, @@ -536,8 +557,8 @@ describe("session.compaction.prune", () => { time: { created: Date.now() }, finish: "end_turn", } - yield* session.updateMessage(b) - yield* session.updatePart({ + yield* ssn.updateMessage(b) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: b.id, sessionID: info.id, @@ -554,7 +575,7 @@ describe("session.compaction.prune", () => { }, }) for (const text of ["second", "third"]) { - const msg = yield* session.updateMessage({ + const msg = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, @@ -562,7 +583,7 @@ describe("session.compaction.prune", () => { model: ref, time: { created: Date.now() }, }) - yield* session.updatePart({ + yield* ssn.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID: info.id, @@ -573,7 +594,7 @@ describe("session.compaction.prune", () => { yield* compact.prune({ sessionID: info.id }) - const msgs = yield* session.messages({ sessionID: info.id }) + const msgs = yield* ssn.messages({ sessionID: info.id }) const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") expect(part?.type).toBe("tool") expect(part?.state.status).toBe("completed") @@ -589,9 +610,9 @@ describe("session.compaction.prune", () => { provideTmpdirInstance((dir) => Effect.gen(function* () { const compact = yield* SessionCompaction.Service - const session = yield* Session.Service - const info = yield* session.create({}) - const a = yield* session.updateMessage({ + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const a = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, @@ -599,7 +620,7 @@ describe("session.compaction.prune", () => { model: ref, time: { created: Date.now() }, }) - yield* session.updatePart({ + yield* ssn.updatePart({ id: PartID.ascending(), messageID: a.id, sessionID: info.id, @@ -626,8 +647,8 @@ describe("session.compaction.prune", () => { time: { created: Date.now() }, finish: "end_turn", } - yield* session.updateMessage(b) - yield* session.updatePart({ + yield* ssn.updateMessage(b) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: b.id, sessionID: info.id, @@ -644,7 +665,7 @@ describe("session.compaction.prune", () => { }, }) for (const text of ["second", "third"]) { - const msg = yield* session.updateMessage({ + const msg = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, @@ -652,7 +673,7 @@ describe("session.compaction.prune", () => { model: ref, time: { created: Date.now() }, }) - yield* session.updatePart({ + yield* ssn.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID: info.id, @@ -663,7 +684,7 @@ describe("session.compaction.prune", () => { yield* compact.prune({ sessionID: info.id }) - const msgs = yield* session.messages({ sessionID: info.id }) + const msgs = yield* ssn.messages({ sessionID: info.id }) const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") expect(part?.type).toBe("tool") if (part?.type === "tool" && part.state.status === "completed") { @@ -680,12 +701,12 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") const reply = await assistant(session.id, msg.id, tmp.path) const rt = runtime("continue") try { - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) await expect( rt.runPromise( SessionCompaction.Service.use((svc) => @@ -710,9 +731,9 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const done = defer() let seen = false const rt = runtime("continue", Plugin.defaultLayer, wide()) @@ -760,11 +781,11 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") const rt = runtime("compact", Plugin.defaultLayer, wide()) try { - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const result = await rt.runPromise( SessionCompaction.Service.use((svc) => svc.process({ @@ -776,7 +797,7 @@ describe("session.compaction.process", () => { ), ) - const summary = (await Session.messages({ sessionID: session.id })).find( + const summary = (await svc.messages({ sessionID: session.id })).find( (msg) => msg.info.role === "assistant" && msg.info.summary, ) @@ -798,11 +819,11 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") const rt = runtime("continue", Plugin.defaultLayer, wide()) try { - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const result = await rt.runPromise( SessionCompaction.Service.use((svc) => svc.process({ @@ -814,7 +835,7 @@ describe("session.compaction.process", () => { ), ) - const all = await Session.messages({ sessionID: session.id }) + const all = await svc.messages({ sessionID: session.id }) const last = all.at(-1) expect(result).toBe("continue") @@ -838,11 +859,11 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") const rt = runtime("continue", autocontinue(false), wide()) try { - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const result = await rt.runPromise( SessionCompaction.Service.use((svc) => svc.process({ @@ -854,7 +875,7 @@ describe("session.compaction.process", () => { ), ) - const all = await Session.messages({ sessionID: session.id }) + const all = await svc.messages({ sessionID: session.id }) const last = all.at(-1) expect(result).toBe("continue") @@ -881,10 +902,10 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await user(session.id, "root") const replay = await user(session.id, "image") - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), messageID: replay.id, sessionID: session.id, @@ -896,7 +917,7 @@ describe("session.compaction.process", () => { const msg = await user(session.id, "current") const rt = runtime("continue", Plugin.defaultLayer, wide()) try { - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const result = await rt.runPromise( SessionCompaction.Service.use((svc) => svc.process({ @@ -909,7 +930,7 @@ describe("session.compaction.process", () => { ), ) - const last = (await Session.messages({ sessionID: session.id })).at(-1) + const last = (await svc.messages({ sessionID: session.id })).at(-1) expect(result).toBe("continue") expect(last?.info.role).toBe("user") @@ -929,13 +950,13 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await user(session.id, "earlier") const msg = await user(session.id, "current") const rt = runtime("continue", Plugin.defaultLayer, wide()) try { - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const result = await rt.runPromise( SessionCompaction.Service.use((svc) => svc.process({ @@ -948,7 +969,7 @@ describe("session.compaction.process", () => { ), ) - const last = (await Session.messages({ sessionID: session.id })).at(-1) + const last = (await svc.messages({ sessionID: session.id })).at(-1) expect(result).toBe("continue") expect(last?.info.role).toBe("user") @@ -989,9 +1010,9 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const abort = new AbortController() const rt = liveRuntime(stub.layer, wide()) let off: (() => void) | undefined @@ -1063,9 +1084,9 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) const abort = new AbortController() const rt = runtime("continue", plugin(ready), wide()) let run: Promise<"continue" | "stop"> | undefined @@ -1100,7 +1121,7 @@ describe("session.compaction.process", () => { abort.abort() expect(await run).toBe("stop") - const all = await Session.messages({ sessionID: session.id }) + const all = await svc.messages({ sessionID: session.id }) expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) } finally { abort.abort() @@ -1165,11 +1186,11 @@ describe("session.compaction.process", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const msg = await user(session.id, "hello") const rt = liveRuntime(stub.layer, wide()) try { - const msgs = await Session.messages({ sessionID: session.id }) + const msgs = await svc.messages({ sessionID: session.id }) await rt.runPromise( SessionCompaction.Service.use((svc) => svc.process({ @@ -1181,7 +1202,7 @@ describe("session.compaction.process", () => { ), ) - const summary = (await Session.messages({ sessionID: session.id })).find( + const summary = (await svc.messages({ sessionID: session.id })).find( (item) => item.info.role === "assistant" && item.info.summary, ) @@ -1211,10 +1232,10 @@ describe("util.token.estimate", () => { }) }) -describe("session.getUsage", () => { +describe("SessionNs.getUsage", () => { test("normalizes standard usage to token format", () => { const model = createModel({ context: 100_000, output: 32_000 }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 1000, @@ -1241,7 +1262,7 @@ describe("session.getUsage", () => { test("extracts cached tokens to cache.read", () => { const model = createModel({ context: 100_000, output: 32_000 }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 1000, @@ -1265,7 +1286,7 @@ describe("session.getUsage", () => { test("handles anthropic cache write metadata", () => { const model = createModel({ context: 100_000, output: 32_000 }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 1000, @@ -1294,7 +1315,7 @@ describe("session.getUsage", () => { test("subtracts cached tokens for anthropic provider", () => { const model = createModel({ context: 100_000, output: 32_000 }) // AI SDK v6 normalizes inputTokens to include cached tokens for all providers - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 1000, @@ -1321,7 +1342,7 @@ describe("session.getUsage", () => { test("separates reasoning tokens from output tokens", () => { const model = createModel({ context: 100_000, output: 32_000 }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 1000, @@ -1355,7 +1376,7 @@ describe("session.getUsage", () => { cache: { read: 0, write: 0 }, }, }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 0, @@ -1380,7 +1401,7 @@ describe("session.getUsage", () => { test("handles undefined optional values gracefully", () => { const model = createModel({ context: 100_000, output: 32_000 }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 0, @@ -1416,7 +1437,7 @@ describe("session.getUsage", () => { cache: { read: 0.3, write: 3.75 }, }, }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 1_000_000, @@ -1457,7 +1478,7 @@ describe("session.getUsage", () => { }, } if (npm === "@ai-sdk/amazon-bedrock") { - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage, metadata: { @@ -1478,7 +1499,7 @@ describe("session.getUsage", () => { return } - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage, metadata: { @@ -1499,7 +1520,7 @@ describe("session.getUsage", () => { test("extracts cache write tokens from vertex metadata key", () => { const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" }) - const result = Session.getUsage({ + const result = SessionNs.getUsage({ model, usage: { inputTokens: 1000, diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index bb9df6aea..668918ec8 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import path from "path" import { Instance } from "../../src/project/instance" -import { Session } from "../../src/session" +import { Session as SessionNs } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -10,12 +11,32 @@ import { Log } from "../../src/util/log" const root = path.join(__dirname, "../..") Log.init({ print: false }) +function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) { + return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) +} + +const svc = { + ...SessionNs, + create(input?: SessionNs.CreateInput) { + return run(SessionNs.Service.use((svc) => svc.create(input))) + }, + remove(id: SessionID) { + return run(SessionNs.Service.use((svc) => svc.remove(id))) + }, + updateMessage<T extends MessageV2.Info>(msg: T) { + return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) + }, + updatePart<T extends MessageV2.Part>(part: T) { + return run(SessionNs.Service.use((svc) => svc.updatePart(part))) + }, +} + async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) { const ids = [] as MessageID[] for (let i = 0; i < count; i++) { const id = MessageID.ascending() ids.push(id) - await Session.updateMessage({ + await svc.updateMessage({ id, sessionID, role: "user", @@ -25,7 +46,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D tools: {}, mode: "", } as unknown as MessageV2.Info) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID, messageID: id, @@ -38,7 +59,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D async function addUser(sessionID: SessionID, text?: string) { const id = MessageID.ascending() - await Session.updateMessage({ + await svc.updateMessage({ id, sessionID, role: "user", @@ -49,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) { mode: "", } as unknown as MessageV2.Info) if (text) { - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID, messageID: id, @@ -66,7 +87,7 @@ async function addAssistant( opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] }, ) { const id = MessageID.ascending() - await Session.updateMessage({ + await svc.updateMessage({ id, sessionID, role: "assistant", @@ -87,7 +108,7 @@ async function addAssistant( } async function addCompactionPart(sessionID: SessionID, messageID: MessageID) { - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID, messageID, @@ -101,14 +122,14 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await fill(session.id, 2) const result = MessageV2.page({ sessionID: session.id, limit: 10 }) expect(result).toBeDefined() expect(result.items).toBeArray() - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -117,7 +138,7 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 6) const a = MessageV2.page({ sessionID: session.id, limit: 2 }) @@ -136,7 +157,7 @@ describe("MessageV2.page", () => { expect(c.more).toBe(false) expect(c.cursor).toBeUndefined() - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -145,13 +166,13 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 4) const result = MessageV2.page({ sessionID: session.id, limit: 4 }) expect(result.items.map((item) => item.info.id)).toEqual(ids) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -160,14 +181,14 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const result = MessageV2.page({ sessionID: session.id, limit: 10 }) expect(result.items).toEqual([]) expect(result.more).toBe(false) expect(result.cursor).toBeUndefined() - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -186,7 +207,7 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 3) const result = MessageV2.page({ sessionID: session.id, limit: 3 }) @@ -194,7 +215,7 @@ describe("MessageV2.page", () => { expect(result.more).toBe(false) expect(result.cursor).toBeUndefined() - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -203,7 +224,7 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 5) const result = MessageV2.page({ sessionID: session.id, limit: 1 }) @@ -211,7 +232,7 @@ describe("MessageV2.page", () => { expect(result.items[0].info.id).toBe(ids[ids.length - 1]) expect(result.more).toBe(true) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -220,10 +241,10 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const [id] = await fill(session.id, 1) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: id, @@ -235,7 +256,7 @@ describe("MessageV2.page", () => { expect(result.items).toHaveLength(1) expect(result.items[0].parts).toHaveLength(2) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -244,7 +265,7 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 4, (i) => 1000.5 + i) const a = MessageV2.page({ sessionID: session.id, limit: 2 }) @@ -253,7 +274,7 @@ describe("MessageV2.page", () => { expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -262,7 +283,7 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 4, () => 1000) const a = MessageV2.page({ sessionID: session.id, limit: 2 }) @@ -273,7 +294,7 @@ describe("MessageV2.page", () => { expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) expect(b.more).toBe(false) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -282,8 +303,8 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const a = await Session.create({}) - const b = await Session.create({}) + const a = await svc.create({}) + const b = await svc.create({}) await fill(a.id, 3) await fill(b.id, 2) @@ -294,8 +315,8 @@ describe("MessageV2.page", () => { expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true) expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true) - await Session.remove(a.id) - await Session.remove(b.id) + await svc.remove(a.id) + await svc.remove(b.id) }, }) }) @@ -304,7 +325,7 @@ describe("MessageV2.page", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 10) const result = MessageV2.page({ sessionID: session.id, limit: 100 }) @@ -313,7 +334,7 @@ describe("MessageV2.page", () => { expect(result.more).toBe(false) expect(result.cursor).toBeUndefined() - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -324,13 +345,13 @@ describe("MessageV2.stream", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 5) const items = Array.from(MessageV2.stream(session.id)) expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -339,12 +360,12 @@ describe("MessageV2.stream", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const items = Array.from(MessageV2.stream(session.id)) expect(items).toHaveLength(0) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -353,14 +374,14 @@ describe("MessageV2.stream", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 1) const items = Array.from(MessageV2.stream(session.id)) expect(items).toHaveLength(1) expect(items[0].info.id).toBe(ids[0]) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -369,7 +390,7 @@ describe("MessageV2.stream", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await fill(session.id, 3) const items = Array.from(MessageV2.stream(session.id)) @@ -378,7 +399,7 @@ describe("MessageV2.stream", () => { expect(item.parts[0].type).toBe("text") } - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -387,7 +408,7 @@ describe("MessageV2.stream", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 60) const items = Array.from(MessageV2.stream(session.id)) @@ -395,7 +416,7 @@ describe("MessageV2.stream", () => { expect(items[0].info.id).toBe(ids[ids.length - 1]) expect(items[59].info.id).toBe(ids[0]) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -404,7 +425,7 @@ describe("MessageV2.stream", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await fill(session.id, 1) const gen = MessageV2.stream(session.id) @@ -414,7 +435,7 @@ describe("MessageV2.stream", () => { expect(first).toHaveProperty("done") expect(first.done).toBe(false) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -425,7 +446,7 @@ describe("MessageV2.parts", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const [id] = await fill(session.id, 1) const result = MessageV2.parts(id) @@ -433,7 +454,7 @@ describe("MessageV2.parts", () => { expect(result[0].type).toBe("text") expect((result[0] as MessageV2.TextPart).text).toBe("m0") - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -442,13 +463,13 @@ describe("MessageV2.parts", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const id = await addUser(session.id) const result = MessageV2.parts(id) expect(result).toEqual([]) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -457,17 +478,17 @@ describe("MessageV2.parts", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const [id] = await fill(session.id, 1) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: id, type: "text", text: "second", }) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: id, @@ -481,7 +502,7 @@ describe("MessageV2.parts", () => { expect((result[1] as MessageV2.TextPart).text).toBe("second") expect((result[2] as MessageV2.TextPart).text).toBe("third") - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -490,7 +511,7 @@ describe("MessageV2.parts", () => { await Instance.provide({ directory: root, fn: async () => { - await Session.create({}) + await svc.create({}) const result = MessageV2.parts(MessageID.ascending()) expect(result).toEqual([]) }, @@ -501,14 +522,14 @@ describe("MessageV2.parts", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const [id] = await fill(session.id, 1) const result = MessageV2.parts(id) expect(result[0].sessionID).toBe(session.id) expect(result[0].messageID).toBe(id) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -519,7 +540,7 @@ describe("MessageV2.get", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const [id] = await fill(session.id, 1) const result = MessageV2.get({ sessionID: session.id, messageID: id }) @@ -529,7 +550,7 @@ describe("MessageV2.get", () => { expect(result.parts).toHaveLength(1) expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -538,13 +559,13 @@ describe("MessageV2.get", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow( "NotFoundError", ) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -553,16 +574,16 @@ describe("MessageV2.get", () => { await Instance.provide({ directory: root, fn: async () => { - const a = await Session.create({}) - const b = await Session.create({}) + const a = await svc.create({}) + const b = await svc.create({}) const [id] = await fill(a.id, 1) expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError") const result = MessageV2.get({ sessionID: a.id, messageID: id }) expect(result.info.id).toBe(id) - await Session.remove(a.id) - await Session.remove(b.id) + await svc.remove(a.id) + await svc.remove(b.id) }, }) }) @@ -571,10 +592,10 @@ describe("MessageV2.get", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const [id] = await fill(session.id, 1) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: id, @@ -585,7 +606,7 @@ describe("MessageV2.get", () => { const result = MessageV2.get({ sessionID: session.id, messageID: id }) expect(result.parts).toHaveLength(2) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -594,11 +615,11 @@ describe("MessageV2.get", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const uid = await addUser(session.id, "hello") const aid = await addAssistant(session.id, uid) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: aid, @@ -611,7 +632,7 @@ describe("MessageV2.get", () => { expect(result.parts).toHaveLength(1) expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -620,14 +641,14 @@ describe("MessageV2.get", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const id = await addUser(session.id) const result = MessageV2.get({ sessionID: session.id, messageID: id }) expect(result.info.id).toBe(id) expect(result.parts).toEqual([]) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -638,7 +659,7 @@ describe("MessageV2.filterCompacted", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 5) const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) @@ -646,7 +667,7 @@ describe("MessageV2.filterCompacted", () => { // reversed from newest-first to chronological expect(result.map((item) => item.info.id)).toEqual(ids) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -655,13 +676,13 @@ describe("MessageV2.filterCompacted", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break const u1 = await addUser(session.id, "first question") const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" }) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: a1, @@ -672,7 +693,7 @@ describe("MessageV2.filterCompacted", () => { const u2 = await addUser(session.id, "new question") const a2 = await addAssistant(session.id, u2) - await Session.updatePart({ + await svc.updatePart({ id: PartID.ascending(), sessionID: session.id, messageID: a2, @@ -685,7 +706,7 @@ describe("MessageV2.filterCompacted", () => { expect(result[0].info.id).toBe(u1) expect(result.length).toBe(4) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -699,7 +720,7 @@ describe("MessageV2.filterCompacted", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const u1 = await addUser(session.id, "hello") await addCompactionPart(session.id, u1) @@ -708,7 +729,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) expect(result).toHaveLength(2) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -717,7 +738,7 @@ describe("MessageV2.filterCompacted", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const u1 = await addUser(session.id, "hello") await addCompactionPart(session.id, u1) @@ -733,7 +754,7 @@ describe("MessageV2.filterCompacted", () => { // Error assistant doesn't add to completed, so compaction boundary never triggers expect(result).toHaveLength(3) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -742,7 +763,7 @@ describe("MessageV2.filterCompacted", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const u1 = await addUser(session.id, "hello") await addCompactionPart(session.id, u1) @@ -754,7 +775,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) expect(result).toHaveLength(3) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -808,7 +829,7 @@ describe("MessageV2 consistency", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await fill(session.id, 3) const paged = MessageV2.page({ sessionID: session.id, limit: 10 }) @@ -818,7 +839,7 @@ describe("MessageV2 consistency", () => { expect(got.parts).toEqual(item.parts) } - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -827,14 +848,14 @@ describe("MessageV2 consistency", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const [id] = await fill(session.id, 1) const got = MessageV2.get({ sessionID: session.id, messageID: id }) const standalone = MessageV2.parts(id) expect(got.parts).toEqual(standalone) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -843,7 +864,7 @@ describe("MessageV2 consistency", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) await fill(session.id, 7) const streamed = Array.from(MessageV2.stream(session.id)) @@ -861,7 +882,7 @@ describe("MessageV2 consistency", () => { expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) @@ -870,7 +891,7 @@ describe("MessageV2 consistency", () => { await Instance.provide({ directory: root, fn: async () => { - const session = await Session.create({}) + const session = await svc.create({}) const ids = await fill(session.id, 4) const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) @@ -878,7 +899,7 @@ describe("MessageV2 consistency", () => { expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) - await Session.remove(session.id) + await svc.remove(session.id) }, }) }) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 98b1fde00..9523915bd 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 bf7b99ef2..c1d6f1da9 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<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) }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 75c74002a..15132a270 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,43 +1,62 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { Session } from "../../src/session" +import { Session as SessionNs } from "../../src/session" import { Bus } from "../../src/bus" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { AppRuntime } from "../../src/effect/app-runtime" import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +function create(input?: SessionNs.CreateInput) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) +} + +function get(id: SessionID) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) +} + +function remove(id: SessionID) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id))) +} + +function updateMessage<T extends MessageV2.Info>(msg: T) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) +} + +function updatePart<T extends MessageV2.Part>(part: T) { + return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) +} + describe("session.created event", () => { test("should emit session.created event when session is created", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { let eventReceived = false - let receivedInfo: Session.Info | undefined + let receivedInfo: SessionNs.Info | undefined - const unsub = Bus.subscribe(Session.Event.Created, (event) => { + const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => { eventReceived = true - receivedInfo = event.properties.info as Session.Info + receivedInfo = event.properties.info as SessionNs.Info }) - const session = await Session.create({}) - + const info = await create({}) await new Promise((resolve) => setTimeout(resolve, 100)) - unsub() expect(eventReceived).toBe(true) expect(receivedInfo).toBeDefined() - expect(receivedInfo?.id).toBe(session.id) - expect(receivedInfo?.projectID).toBe(session.projectID) - expect(receivedInfo?.directory).toBe(session.directory) - expect(receivedInfo?.title).toBe(session.title) + expect(receivedInfo?.id).toBe(info.id) + expect(receivedInfo?.projectID).toBe(info.projectID) + expect(receivedInfo?.directory).toBe(info.directory) + expect(receivedInfo?.title).toBe(info.title) - await Session.remove(session.id) + await remove(info.id) }, }) }) @@ -48,18 +67,16 @@ describe("session.created event", () => { fn: async () => { const events: string[] = [] - const unsubCreated = Bus.subscribe(Session.Event.Created, () => { + const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => { events.push("created") }) - const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => { + const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => { events.push("updated") }) - const session = await Session.create({}) - + const info = await create({}) await new Promise((resolve) => setTimeout(resolve, 100)) - unsubCreated() unsubUpdated() @@ -67,7 +84,7 @@ describe("session.created event", () => { expect(events).toContain("updated") expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated")) - await Session.remove(session.id) + await remove(info.id) }, }) }) @@ -80,12 +97,12 @@ describe("step-finish token propagation via Bus event", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const session = await Session.create({}) + const info = await create({}) const messageID = MessageID.ascending() - await Session.updateMessage({ + await updateMessage({ id: messageID, - sessionID: session.id, + sessionID: info.id, role: "user", time: { created: Date.now() }, agent: "user", @@ -110,15 +127,14 @@ describe("step-finish token propagation via Bus event", () => { const partInput = { id: PartID.ascending(), messageID, - sessionID: session.id, + sessionID: info.id, type: "step-finish" as const, reason: "stop", cost: 0.005, tokens, } - await Session.updatePart(partInput) - + await updatePart(partInput) await new Promise((resolve) => setTimeout(resolve, 100)) expect(received).toBeDefined() @@ -134,7 +150,7 @@ describe("step-finish token propagation via Bus event", () => { expect(received).not.toBe(partInput) unsub() - await Session.remove(session.id) + await remove(info.id) }, }) }, @@ -146,17 +162,17 @@ describe("Session", () => { test("remove works without an instance", async () => { await using tmp = await tmpdir({ git: true }) - const session = await Instance.provide({ + const info = await Instance.provide({ directory: tmp.path, - fn: async () => Session.create({ title: "remove-without-instance" }), + fn: () => create({ title: "remove-without-instance" }), }) await expect(async () => { - await Session.remove(session.id) + await remove(info.id) }).not.toThrow() let missing = false - await Session.get(session.id).catch(() => { + await get(info.id).catch(() => { missing = true }) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index c9c543656..64266de47 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<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, ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 436c46490..e7a143c9a 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<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts { +function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts { const id = MessageID.ascending() return { info: { |
